Compare commits
20 Commits
09770ec625
...
547f690e3c
| Author | SHA1 | Date | |
|---|---|---|---|
| 547f690e3c | |||
| 545271e9bd | |||
| 7800d3474d | |||
| 9ac5320782 | |||
| 645602b2de | |||
| d56b0b71d7 | |||
| 2c5b264f9b | |||
| a470d8984c | |||
| 45ad602d9a | |||
| 98d72d0db8 | |||
| 889e91a8ca | |||
| 582ed3237d | |||
| c358d3b2ae | |||
| 4d79f08089 | |||
| 2b0f36a12c | |||
| 71b27b671b | |||
| 21e5d901c7 | |||
| 2cd2946a80 | |||
| 1dc64ebf69 | |||
| 7234cd718a |
@ -2606,6 +2606,7 @@
|
|||||||
},
|
},
|
||||||
"Play Again" : {
|
"Play Again" : {
|
||||||
"comment" : "A button label that says \"Play Again\".",
|
"comment" : "A button label that says \"Play Again\".",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -2832,6 +2833,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Reset Game" : {
|
||||||
|
"comment" : "A button label that resets the game balance and reshuffles the deck.",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Reset Game"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Reiniciar juego"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Réinitialiser la partie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Reset to Defaults" : {
|
"Reset to Defaults" : {
|
||||||
"comment" : "A button label that resets game settings to their default values.",
|
"comment" : "A button label that resets game settings to their default values.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -2855,6 +2879,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Restore starting balance and reshuffle" : {
|
||||||
|
"comment" : "Description for the reset game button explaining what it does.",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Restore starting balance and reshuffle"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Restaurar saldo inicial y barajar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Restaurer le solde initial et mélanger"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Roulette" : {
|
"Roulette" : {
|
||||||
"comment" : "The name of a roulette game.",
|
"comment" : "The name of a roulette game.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -3496,6 +3543,7 @@
|
|||||||
},
|
},
|
||||||
"TOTAL" : {
|
"TOTAL" : {
|
||||||
"comment" : "A label displayed next to the total winnings in the result banner.",
|
"comment" : "A label displayed next to the total winnings in the result banner.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -3794,6 +3842,7 @@
|
|||||||
},
|
},
|
||||||
"You've run out of chips!" : {
|
"You've run out of chips!" : {
|
||||||
"comment" : "A message displayed when a player runs out of money in the game over screen.",
|
"comment" : "A message displayed when a player runs out of money in the game over screen.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
@ -50,6 +50,15 @@ enum Design {
|
|||||||
static let labelFontSize: CGFloat = 14
|
static let labelFontSize: CGFloat = 14
|
||||||
static let labelRowHeight: CGFloat = 30
|
static let labelRowHeight: CGFloat = 30
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Card Deal Animation
|
||||||
|
|
||||||
|
enum DealAnimation {
|
||||||
|
/// Horizontal offset for card deal (from upper-center, simulating dealer)
|
||||||
|
static let offsetX: CGFloat = 0
|
||||||
|
/// Vertical offset for card deal (from above the table)
|
||||||
|
static let offsetY: CGFloat = -250
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Baccarat App Colors
|
// MARK: - Baccarat App Colors
|
||||||
|
|||||||
@ -135,7 +135,6 @@ struct GameTableView: View {
|
|||||||
balance: state.balance,
|
balance: state.balance,
|
||||||
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.shoe.cardsRemaining)" : nil,
|
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.shoe.cardsRemaining)" : nil,
|
||||||
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
|
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
|
||||||
onReset: { state.resetGame() },
|
|
||||||
onSettings: { showSettings = true },
|
onSettings: { showSettings = true },
|
||||||
onHelp: { showRules = true },
|
onHelp: { showRules = true },
|
||||||
onStats: { showStats = true }
|
onStats: { showStats = true }
|
||||||
@ -194,7 +193,9 @@ struct GameTableView: View {
|
|||||||
bankerValue: state.bankerHandValue,
|
bankerValue: state.bankerHandValue,
|
||||||
playerIsWinner: playerIsWinner,
|
playerIsWinner: playerIsWinner,
|
||||||
bankerIsWinner: bankerIsWinner,
|
bankerIsWinner: bankerIsWinner,
|
||||||
isTie: isTie
|
isTie: isTie,
|
||||||
|
showAnimations: settings.showAnimations,
|
||||||
|
dealingSpeed: settings.dealingSpeed
|
||||||
)
|
)
|
||||||
.frame(maxWidth: maxContentWidth)
|
.frame(maxWidth: maxContentWidth)
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
@ -213,14 +214,13 @@ struct GameTableView: View {
|
|||||||
|
|
||||||
Spacer(minLength: Design.Spacing.xSmall)
|
Spacer(minLength: Design.Spacing.xSmall)
|
||||||
|
|
||||||
// Chip selector
|
// Chip selector - full width so all chips are tappable
|
||||||
ChipSelectorView(
|
ChipSelectorView(
|
||||||
selectedChip: $selectedChip,
|
selectedChip: $selectedChip,
|
||||||
balance: state.balance,
|
balance: state.balance,
|
||||||
currentBet: state.totalBetAmount,
|
currentBet: state.totalBetAmount,
|
||||||
maxBet: state.maxBet
|
maxBet: state.maxBet
|
||||||
)
|
)
|
||||||
.frame(maxWidth: maxContentWidth)
|
|
||||||
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
|
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
|
||||||
|
|
||||||
Spacer(minLength: Design.Spacing.xSmall)
|
Spacer(minLength: Design.Spacing.xSmall)
|
||||||
@ -256,7 +256,6 @@ struct GameTableView: View {
|
|||||||
balance: state.balance,
|
balance: state.balance,
|
||||||
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.shoe.cardsRemaining)" : nil,
|
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.shoe.cardsRemaining)" : nil,
|
||||||
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
|
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
|
||||||
onReset: { state.resetGame() },
|
|
||||||
onSettings: { showSettings = true },
|
onSettings: { showSettings = true },
|
||||||
onHelp: { showRules = true },
|
onHelp: { showRules = true },
|
||||||
onStats: { showStats = true }
|
onStats: { showStats = true }
|
||||||
@ -276,7 +275,9 @@ struct GameTableView: View {
|
|||||||
bankerValue: state.bankerHandValue,
|
bankerValue: state.bankerHandValue,
|
||||||
playerIsWinner: playerIsWinner,
|
playerIsWinner: playerIsWinner,
|
||||||
bankerIsWinner: bankerIsWinner,
|
bankerIsWinner: bankerIsWinner,
|
||||||
isTie: isTie
|
isTie: isTie,
|
||||||
|
showAnimations: settings.showAnimations,
|
||||||
|
dealingSpeed: settings.dealingSpeed
|
||||||
)
|
)
|
||||||
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
|
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
@ -308,14 +309,13 @@ struct GameTableView: View {
|
|||||||
Spacer(minLength: mediumSpacerHeight)
|
Spacer(minLength: mediumSpacerHeight)
|
||||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer4")
|
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer4")
|
||||||
|
|
||||||
// Chip selector (from CasinoKit)
|
// Chip selector - full width so all chips are tappable
|
||||||
ChipSelectorView(
|
ChipSelectorView(
|
||||||
selectedChip: $selectedChip,
|
selectedChip: $selectedChip,
|
||||||
balance: state.balance,
|
balance: state.balance,
|
||||||
currentBet: state.totalBetAmount,
|
currentBet: state.totalBetAmount,
|
||||||
maxBet: state.maxBet
|
maxBet: state.maxBet
|
||||||
)
|
)
|
||||||
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
|
|
||||||
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
|
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
|
||||||
|
|
||||||
Spacer(minLength: smallSpacerHeight)
|
Spacer(minLength: smallSpacerHeight)
|
||||||
|
|||||||
@ -3,12 +3,14 @@
|
|||||||
// Baccarat
|
// Baccarat
|
||||||
//
|
//
|
||||||
// Animated result banner showing the winner and itemized bet results.
|
// Animated result banner showing the winner and itemized bet results.
|
||||||
|
// Uses the shared ResultBannerView from CasinoKit.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CasinoKit
|
import CasinoKit
|
||||||
|
|
||||||
/// An animated banner showing the round result with bet breakdown.
|
/// An animated banner showing the round result with bet breakdown.
|
||||||
|
/// This is a wrapper around CasinoKit's shared ResultBannerView.
|
||||||
struct ResultBannerView: View {
|
struct ResultBannerView: View {
|
||||||
let result: GameResult
|
let result: GameResult
|
||||||
let totalWinnings: Int
|
let totalWinnings: Int
|
||||||
@ -20,30 +22,6 @@ struct ResultBannerView: View {
|
|||||||
let onNewRound: () -> Void
|
let onNewRound: () -> Void
|
||||||
let onGameOver: () -> Void
|
let onGameOver: () -> Void
|
||||||
|
|
||||||
/// Whether the player is out of money and can't continue.
|
|
||||||
private var isGameOver: Bool {
|
|
||||||
currentBalance < minBet
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var showBanner = false
|
|
||||||
@State private var showText = false
|
|
||||||
@State private var showBreakdown = false
|
|
||||||
@State private var showTotal = false
|
|
||||||
@State private var showButton = false
|
|
||||||
|
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
||||||
|
|
||||||
/// Maximum width for the banner card on iPad
|
|
||||||
private var maxBannerWidth: CGFloat {
|
|
||||||
horizontalSizeClass == .regular ? CasinoDesign.Size.maxModalWidth : .infinity
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Scaled Font Sizes (Dynamic Type)
|
|
||||||
|
|
||||||
@ScaledMetric(relativeTo: .largeTitle) private var resultFontSize: CGFloat = Design.BaseFontSize.largeTitle
|
|
||||||
@ScaledMetric(relativeTo: .title3) private var totalFontSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall
|
|
||||||
@ScaledMetric(relativeTo: .body) private var itemFontSize: CGFloat = Design.BaseFontSize.medium
|
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
private var winningBets: [BetResult] {
|
private var winningBets: [BetResult] {
|
||||||
@ -59,28 +37,13 @@ struct ResultBannerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
CasinoResultBannerView(
|
||||||
// Background overlay
|
resultText: result.displayText,
|
||||||
Color.black.opacity(showBanner ? Design.Opacity.medium : 0)
|
resultColor: result.color,
|
||||||
.ignoresSafeArea()
|
totalWinnings: totalWinnings,
|
||||||
.animation(.easeIn(duration: Design.Animation.fadeInDuration), value: showBanner)
|
currentBalance: currentBalance,
|
||||||
|
minBet: minBet,
|
||||||
// Banner
|
breakdownContent: {
|
||||||
VStack(spacing: Design.Spacing.medium) {
|
|
||||||
// Result text
|
|
||||||
Text(result.displayText)
|
|
||||||
.font(.system(size: resultFontSize, weight: .black, design: .rounded))
|
|
||||||
.foregroundStyle(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.white, result.color],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.shadow(color: result.color.opacity(Design.Opacity.heavy), radius: Design.Shadow.radiusLarge)
|
|
||||||
.scaleEffect(showText ? Design.Scale.normal : Design.Scale.shrunk)
|
|
||||||
.opacity(showText ? Design.Scale.normal : 0)
|
|
||||||
|
|
||||||
// Pair indicators
|
// Pair indicators
|
||||||
if playerHadPair || bankerHadPair {
|
if playerHadPair || bankerHadPair {
|
||||||
HStack(spacing: Design.Spacing.large) {
|
HStack(spacing: Design.Spacing.large) {
|
||||||
@ -91,8 +54,6 @@ struct ResultBannerView: View {
|
|||||||
PairBadge(label: "B PAIR", color: .red)
|
PairBadge(label: "B PAIR", color: .red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scaleEffect(showBreakdown ? Design.Scale.normal : Design.Scale.shrunk)
|
|
||||||
.opacity(showBreakdown ? Design.Scale.normal : 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bet breakdown
|
// Bet breakdown
|
||||||
@ -100,204 +61,13 @@ struct ResultBannerView: View {
|
|||||||
BetBreakdownView(
|
BetBreakdownView(
|
||||||
winningBets: winningBets,
|
winningBets: winningBets,
|
||||||
losingBets: losingBets,
|
losingBets: losingBets,
|
||||||
pushBets: pushBets,
|
pushBets: pushBets
|
||||||
fontSize: itemFontSize
|
|
||||||
)
|
)
|
||||||
.scaleEffect(showBreakdown ? Design.Scale.normal : Design.Scale.shrunk)
|
|
||||||
.opacity(showBreakdown ? Design.Scale.normal : 0)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
// Total
|
onNewRound: onNewRound,
|
||||||
if totalWinnings != 0 {
|
onPlayAgain: onGameOver
|
||||||
HStack(spacing: Design.Spacing.small) {
|
|
||||||
Text("TOTAL")
|
|
||||||
.font(.system(size: Design.BaseFontSize.body, weight: .bold))
|
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.accent))
|
|
||||||
|
|
||||||
if totalWinnings > 0 {
|
|
||||||
Text("+\(totalWinnings)")
|
|
||||||
.font(.system(size: totalFontSize, weight: .black, design: .rounded))
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
} else {
|
|
||||||
Text("\(totalWinnings)")
|
|
||||||
.font(.system(size: totalFontSize, weight: .black, design: .rounded))
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scaleEffect(showTotal ? Design.Scale.normal : Design.Scale.shrunk)
|
|
||||||
.opacity(showTotal ? Design.Scale.normal : 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Game Over message or New Round button
|
|
||||||
if isGameOver {
|
|
||||||
// Game Over - show message and restart button
|
|
||||||
VStack(spacing: Design.Spacing.medium) {
|
|
||||||
Text(String(localized: "You've run out of chips!"))
|
|
||||||
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
|
||||||
.foregroundStyle(.red.opacity(Design.Opacity.heavy))
|
|
||||||
|
|
||||||
Button {
|
|
||||||
onGameOver()
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: Design.Spacing.small) {
|
|
||||||
Image(systemName: "arrow.counterclockwise")
|
|
||||||
Text(String(localized: "Play Again"))
|
|
||||||
}
|
|
||||||
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold))
|
|
||||||
.foregroundStyle(.black)
|
|
||||||
.padding(.horizontal, Design.Spacing.xxxLarge + Design.Spacing.small)
|
|
||||||
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
|
|
||||||
.background(
|
|
||||||
Capsule()
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
)
|
|
||||||
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scaleEffect(showButton ? Design.Scale.normal : Design.Scale.shrunk)
|
|
||||||
.opacity(showButton ? Design.Scale.normal : 0)
|
|
||||||
.padding(.top, Design.Spacing.small)
|
|
||||||
} else {
|
|
||||||
// Normal - New Round button
|
|
||||||
Button {
|
|
||||||
onNewRound()
|
|
||||||
} label: {
|
|
||||||
Text(String(localized: "New Round"))
|
|
||||||
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold))
|
|
||||||
.foregroundStyle(.black)
|
|
||||||
.padding(.horizontal, Design.Spacing.xxxLarge + Design.Spacing.small)
|
|
||||||
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
|
|
||||||
.background(
|
|
||||||
Capsule()
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium)
|
|
||||||
}
|
|
||||||
.scaleEffect(showButton ? Design.Scale.normal : Design.Scale.shrunk)
|
|
||||||
.opacity(showButton ? Design.Scale.normal : 0)
|
|
||||||
.padding(.top, Design.Spacing.small)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, Design.Spacing.xLarge)
|
|
||||||
.padding(.vertical, Design.Spacing.xxLarge)
|
|
||||||
.frame(maxWidth: maxBannerWidth)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge)
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [
|
|
||||||
Color(white: 0.15),
|
|
||||||
Color(white: 0.08)
|
|
||||||
],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge)
|
|
||||||
.strokeBorder(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [
|
|
||||||
result.color.opacity(Design.Opacity.heavy),
|
|
||||||
result.color.opacity(Design.Opacity.light)
|
|
||||||
],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
),
|
|
||||||
lineWidth: Design.LineWidth.thick
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.shadow(color: result.color.opacity(Design.Opacity.light), radius: Design.Shadow.radiusXXLarge)
|
|
||||||
.padding(.horizontal, Design.Spacing.large)
|
|
||||||
.scaleEffect(showBanner ? Design.Scale.normal : Design.Scale.slightShrink)
|
|
||||||
.opacity(showBanner ? Design.Scale.normal : 0)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) {
|
|
||||||
showBanner = true
|
|
||||||
}
|
|
||||||
|
|
||||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay1)) {
|
|
||||||
showText = true
|
|
||||||
}
|
|
||||||
|
|
||||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay2)) {
|
|
||||||
showBreakdown = true
|
|
||||||
}
|
|
||||||
|
|
||||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay3)) {
|
|
||||||
showTotal = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show button after everything else
|
|
||||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay3 + Design.Animation.staggerDelay1)) {
|
|
||||||
showButton = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Play game over sound if out of chips (after a short delay so it doesn't overlap with lose sound)
|
|
||||||
if isGameOver {
|
|
||||||
Task {
|
|
||||||
try? await Task.sleep(for: .seconds(1))
|
|
||||||
SoundManager.shared.play(.gameOver)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Announce result to VoiceOver users
|
|
||||||
announceResult()
|
|
||||||
}
|
|
||||||
.accessibilityElement(children: .ignore)
|
|
||||||
.accessibilityLabel(accessibilityDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Accessibility
|
|
||||||
|
|
||||||
private var accessibilityDescription: String {
|
|
||||||
var description = result.displayText
|
|
||||||
|
|
||||||
// Add pair information
|
|
||||||
if playerHadPair {
|
|
||||||
description += ". Player pair"
|
|
||||||
}
|
|
||||||
if bankerHadPair {
|
|
||||||
description += ". Banker pair"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add bet results
|
|
||||||
for bet in winningBets {
|
|
||||||
description += ". \(bet.displayName) won \(bet.payout)"
|
|
||||||
}
|
|
||||||
for bet in losingBets {
|
|
||||||
description += ". \(bet.displayName) lost \(abs(bet.payout))"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add total
|
|
||||||
if totalWinnings > 0 {
|
|
||||||
description += ". Total winnings: \(totalWinnings)"
|
|
||||||
} else if totalWinnings < 0 {
|
|
||||||
description += ". Total loss: \(abs(totalWinnings))"
|
|
||||||
}
|
|
||||||
|
|
||||||
return description
|
|
||||||
}
|
|
||||||
|
|
||||||
private func announceResult() {
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(for: .milliseconds(500))
|
|
||||||
AccessibilityNotification.Announcement(accessibilityDescription).post()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -307,10 +77,11 @@ private struct BetBreakdownView: View {
|
|||||||
let winningBets: [BetResult]
|
let winningBets: [BetResult]
|
||||||
let losingBets: [BetResult]
|
let losingBets: [BetResult]
|
||||||
let pushBets: [BetResult]
|
let pushBets: [BetResult]
|
||||||
let fontSize: CGFloat
|
|
||||||
|
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = Design.BaseFontSize.medium
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: Design.Spacing.xSmall) {
|
ResultBreakdownCard {
|
||||||
// Winning bets
|
// Winning bets
|
||||||
ForEach(winningBets) { bet in
|
ForEach(winningBets) { bet in
|
||||||
BetResultRow(bet: bet, fontSize: fontSize)
|
BetResultRow(bet: bet, fontSize: fontSize)
|
||||||
@ -326,12 +97,6 @@ private struct BetBreakdownView: View {
|
|||||||
BetResultRow(bet: bet, fontSize: fontSize)
|
BetResultRow(bet: bet, fontSize: fontSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
|
||||||
.padding(.vertical, Design.Spacing.small)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
|
||||||
.fill(Color.white.opacity(Design.Opacity.verySubtle))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -400,7 +165,7 @@ private struct PairBadge: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: ConfettiView is now provided by CasinoKit
|
// MARK: - Previews
|
||||||
|
|
||||||
#Preview("Win") {
|
#Preview("Win") {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
|||||||
@ -150,7 +150,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
|
|
||||||
if gameState.iCloudEnabled {
|
if gameState.iCloudEnabled {
|
||||||
Divider()
|
Divider()
|
||||||
@ -173,6 +173,7 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.background(Color.white.opacity(Design.Opacity.subtle))
|
.background(Color.white.opacity(Design.Opacity.subtle))
|
||||||
@ -181,11 +182,13 @@ struct SettingsView: View {
|
|||||||
gameState.syncWithCloud()
|
gameState.syncWithCloud()
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
|
||||||
Text(String(localized: "Sync Now"))
|
Text(String(localized: "Sync Now"))
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
}
|
}
|
||||||
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -203,7 +206,7 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,15 +240,43 @@ struct SettingsView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
.background(Color.white.opacity(Design.Opacity.subtle))
|
.background(Color.white.opacity(Design.Opacity.subtle))
|
||||||
|
|
||||||
|
// Reset Game - resets balance, keeps stats
|
||||||
|
Button {
|
||||||
|
gameState.resetGame()
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(String(localized: "Reset Game"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text(String(localized: "Restore starting balance and reshuffle"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.small))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
.font(.system(size: Design.BaseFontSize.large))
|
||||||
|
.foregroundStyle(Color.Sheet.accent)
|
||||||
|
}
|
||||||
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(Design.Opacity.subtle))
|
||||||
|
|
||||||
|
// Clear All Data - nuclear option
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
showClearDataAlert = true
|
showClearDataAlert = true
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "trash")
|
|
||||||
Text(String(localized: "Clear All Data"))
|
Text(String(localized: "Clear All Data"))
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "trash")
|
||||||
}
|
}
|
||||||
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -263,6 +294,7 @@ struct SettingsView: View {
|
|||||||
.font(.system(size: Design.BaseFontSize.body))
|
.font(.system(size: Design.BaseFontSize.body))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
}
|
}
|
||||||
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,8 @@ struct CardsDisplayArea: View {
|
|||||||
let playerIsWinner: Bool
|
let playerIsWinner: Bool
|
||||||
let bankerIsWinner: Bool
|
let bankerIsWinner: Bool
|
||||||
let isTie: Bool
|
let isTie: Bool
|
||||||
|
let showAnimations: Bool
|
||||||
|
let dealingSpeed: Double
|
||||||
|
|
||||||
// MARK: - State
|
// MARK: - State
|
||||||
|
|
||||||
@ -147,7 +149,9 @@ struct CardsDisplayArea: View {
|
|||||||
cards: playerCards,
|
cards: playerCards,
|
||||||
cardsFaceUp: playerCardsFaceUp,
|
cardsFaceUp: playerCardsFaceUp,
|
||||||
isWinner: playerIsWinner,
|
isWinner: playerIsWinner,
|
||||||
containerWidth: width
|
containerWidth: width,
|
||||||
|
showAnimations: showAnimations,
|
||||||
|
dealingSpeed: dealingSpeed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(width: width)
|
.frame(width: width)
|
||||||
@ -175,7 +179,9 @@ struct CardsDisplayArea: View {
|
|||||||
cards: bankerCards,
|
cards: bankerCards,
|
||||||
cardsFaceUp: bankerCardsFaceUp,
|
cardsFaceUp: bankerCardsFaceUp,
|
||||||
isWinner: bankerIsWinner,
|
isWinner: bankerIsWinner,
|
||||||
containerWidth: width
|
containerWidth: width,
|
||||||
|
showAnimations: showAnimations,
|
||||||
|
dealingSpeed: dealingSpeed
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(width: width)
|
.frame(width: width)
|
||||||
@ -199,7 +205,9 @@ struct CardsDisplayArea: View {
|
|||||||
bankerValue: 0,
|
bankerValue: 0,
|
||||||
playerIsWinner: false,
|
playerIsWinner: false,
|
||||||
bankerIsWinner: false,
|
bankerIsWinner: false,
|
||||||
isTie: false
|
isTie: false,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -222,7 +230,9 @@ struct CardsDisplayArea: View {
|
|||||||
bankerValue: 2,
|
bankerValue: 2,
|
||||||
playerIsWinner: true,
|
playerIsWinner: true,
|
||||||
bankerIsWinner: false,
|
bankerIsWinner: false,
|
||||||
isTie: false
|
isTie: false,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -247,7 +257,9 @@ struct CardsDisplayArea: View {
|
|||||||
bankerValue: 8,
|
bankerValue: 8,
|
||||||
playerIsWinner: false,
|
playerIsWinner: false,
|
||||||
bankerIsWinner: true,
|
bankerIsWinner: true,
|
||||||
isTie: false
|
isTie: false,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,11 +15,20 @@ struct CompactHandView: View {
|
|||||||
let isWinner: Bool
|
let isWinner: Bool
|
||||||
/// Container width passed from parent for sizing
|
/// Container width passed from parent for sizing
|
||||||
let containerWidth: CGFloat
|
let containerWidth: CGFloat
|
||||||
|
/// Whether to show dealing animations
|
||||||
|
let showAnimations: Bool
|
||||||
|
/// Speed multiplier for dealing animations
|
||||||
|
let dealingSpeed: Double
|
||||||
|
|
||||||
// MARK: - Environment
|
// MARK: - Environment
|
||||||
|
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
|
/// Scaled animation duration based on dealing speed.
|
||||||
|
private var animationDuration: Double {
|
||||||
|
Design.Animation.springDuration * dealingSpeed
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
|
|
||||||
/// Overlap ratio relative to card width (negative = overlap)
|
/// Overlap ratio relative to card width (negative = overlap)
|
||||||
@ -90,10 +99,25 @@ struct CompactHandView: View {
|
|||||||
cardWidth: cardWidth
|
cardWidth: cardWidth
|
||||||
)
|
)
|
||||||
.zIndex(Double(index))
|
.zIndex(Double(index))
|
||||||
|
.transition(
|
||||||
|
showAnimations
|
||||||
|
? .asymmetric(
|
||||||
|
insertion: .offset(x: Design.DealAnimation.offsetX, y: Design.DealAnimation.offsetY)
|
||||||
|
.combined(with: .opacity)
|
||||||
|
.combined(with: .scale(scale: Design.Scale.slightShrink)),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
)
|
||||||
|
: .identity
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(nil, value: cards.count) // Prevent size animation during dealing
|
.animation(
|
||||||
|
showAnimations
|
||||||
|
? .spring(duration: animationDuration, bounce: Design.Animation.springBounce)
|
||||||
|
: .none,
|
||||||
|
value: cards.count
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var winnerBorder: some View {
|
private var winnerBorder: some View {
|
||||||
@ -130,7 +154,9 @@ struct CompactHandView: View {
|
|||||||
cards: [],
|
cards: [],
|
||||||
cardsFaceUp: [],
|
cardsFaceUp: [],
|
||||||
isWinner: false,
|
isWinner: false,
|
||||||
containerWidth: 160
|
containerWidth: 160,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -145,7 +171,9 @@ struct CompactHandView: View {
|
|||||||
],
|
],
|
||||||
cardsFaceUp: [true, true],
|
cardsFaceUp: [true, true],
|
||||||
isWinner: false,
|
isWinner: false,
|
||||||
containerWidth: 160
|
containerWidth: 160,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -161,7 +189,9 @@ struct CompactHandView: View {
|
|||||||
],
|
],
|
||||||
cardsFaceUp: [true, true, true],
|
cardsFaceUp: [true, true, true],
|
||||||
isWinner: true,
|
isWinner: true,
|
||||||
containerWidth: 160
|
containerWidth: 160,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -176,7 +206,9 @@ struct CompactHandView: View {
|
|||||||
],
|
],
|
||||||
cardsFaceUp: [false, false],
|
cardsFaceUp: [false, false],
|
||||||
isWinner: false,
|
isWinner: false,
|
||||||
containerWidth: 160
|
containerWidth: 160,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
173
Baccarat/README.md
Normal file
173
Baccarat/README.md
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
# Baccarat - Casino Card Game
|
||||||
|
|
||||||
|
A feature-rich Baccarat (Punto Banco) app for iOS built with SwiftUI. Experience authentic casino gameplay with side bets, road map history, and detailed statistics — all without risking real money.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🎰 Authentic Punto Banco Gameplay
|
||||||
|
- Complete Baccarat rules with automatic third card logic
|
||||||
|
- Natural detection (8 or 9 on initial deal)
|
||||||
|
- Multi-deck shoe support (1, 6, or 8 decks)
|
||||||
|
- Animated card dealing with sound effects and haptics
|
||||||
|
- Automatic shoe reshuffling with burn card
|
||||||
|
|
||||||
|
### 💰 Betting Options
|
||||||
|
|
||||||
|
#### Main Bets
|
||||||
|
| Bet Type | Payout | House Edge |
|
||||||
|
|----------|--------|------------|
|
||||||
|
| Player | 1:1 | 1.24% |
|
||||||
|
| Banker | 0.95:1 (5% commission) | 1.06% |
|
||||||
|
| Tie | 8:1 | 14.4% |
|
||||||
|
|
||||||
|
#### Side Bets
|
||||||
|
| Bet Type | Payout |
|
||||||
|
|----------|--------|
|
||||||
|
| Player Pair | 11:1 |
|
||||||
|
| Banker Pair | 11:1 |
|
||||||
|
| Dragon Bonus (Natural) | 1:1 |
|
||||||
|
| Dragon Bonus (Win by 4) | 1:1 |
|
||||||
|
| Dragon Bonus (Win by 5) | 2:1 |
|
||||||
|
| Dragon Bonus (Win by 6) | 4:1 |
|
||||||
|
| Dragon Bonus (Win by 7) | 6:1 |
|
||||||
|
| Dragon Bonus (Win by 8) | 10:1 |
|
||||||
|
| Dragon Bonus (Win by 9) | 30:1 |
|
||||||
|
|
||||||
|
### 📊 Road Map History
|
||||||
|
- Visual "Big Road" style result display
|
||||||
|
- Color-coded outcomes (Blue=Player, Red=Banker, Green=Tie)
|
||||||
|
- Pair markers (yellow dot)
|
||||||
|
- Natural markers (star)
|
||||||
|
- Scrollable session history
|
||||||
|
|
||||||
|
### 🎚️ Table Limits
|
||||||
|
| Level | Min Bet | Max Bet |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| Casual | $5 | $500 |
|
||||||
|
| Low Stakes | $10 | $1,000 |
|
||||||
|
| Medium Stakes | $25 | $5,000 |
|
||||||
|
| High Stakes | $100 | $10,000 |
|
||||||
|
| VIP | $500 | $50,000 |
|
||||||
|
|
||||||
|
### ☁️ iCloud Sync
|
||||||
|
- Balance and statistics sync across devices
|
||||||
|
- Settings persist via iCloud
|
||||||
|
- Automatic conflict resolution
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Baccarat/
|
||||||
|
├── BaccaratApp.swift # App entry point
|
||||||
|
├── ContentView.swift # Root view
|
||||||
|
├── Engine/
|
||||||
|
│ ├── BaccaratEngine.swift # Core game logic, third card rules, payouts
|
||||||
|
│ └── GameState.swift # Observable state machine
|
||||||
|
├── Models/
|
||||||
|
│ ├── BetType.swift # Bet types, payouts, Dragon Bonus table
|
||||||
|
│ ├── GameResult.swift # Round outcomes
|
||||||
|
│ ├── GameSettings.swift # User preferences, table limits
|
||||||
|
│ ├── Hand.swift # Baccarat hand model
|
||||||
|
│ └── Shoe.swift # Multi-deck shoe with shuffle/burn
|
||||||
|
├── Storage/
|
||||||
|
│ └── BaccaratGameData.swift # Persistence models
|
||||||
|
├── Theme/
|
||||||
|
│ └── DesignConstants.swift # Design system tokens
|
||||||
|
├── Views/
|
||||||
|
│ ├── Development/ # Dev-only views (branding, icons)
|
||||||
|
│ ├── Game/ # Main game UI components
|
||||||
|
│ ├── Sheets/ # Modal views (settings, stats, rules)
|
||||||
|
│ └── Table/ # Table layout, betting zones, road map
|
||||||
|
└── Resources/
|
||||||
|
└── Localizable.xcstrings # Localization strings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Game Rules
|
||||||
|
|
||||||
|
### Card Values
|
||||||
|
- **Ace**: 1 point
|
||||||
|
- **2-9**: Face value
|
||||||
|
- **10, J, Q, K**: 0 points
|
||||||
|
- Hand value = sum mod 10 (e.g., 7+8=15 → 5)
|
||||||
|
|
||||||
|
### Third Card Rules
|
||||||
|
|
||||||
|
#### Player
|
||||||
|
- 0-5: Draws third card
|
||||||
|
- 6-7: Stands
|
||||||
|
- 8-9: Natural (no third card)
|
||||||
|
|
||||||
|
#### Banker (when Player draws)
|
||||||
|
| Banker Total | Draws if Player's 3rd is... |
|
||||||
|
|--------------|----------------------------|
|
||||||
|
| 0-2 | Always draws |
|
||||||
|
| 3 | 0-7, 9 (not 8) |
|
||||||
|
| 4 | 2-7 |
|
||||||
|
| 5 | 4-7 |
|
||||||
|
| 6 | 6-7 |
|
||||||
|
| 7 | Never draws |
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **iOS 26.0+** target
|
||||||
|
- **Swift 6.2** with strict concurrency
|
||||||
|
- **SwiftUI** with `@Observable` for state management
|
||||||
|
- **CasinoKit** — Shared package for cards, chips, sounds, and common UI
|
||||||
|
- **CloudKit** — iCloud sync for game data and settings
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Xcode 16.0+
|
||||||
|
- iOS 26.0+ deployment target
|
||||||
|
- Swift 6.2+
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Open `CasinoGames.xcworkspace` in Xcode
|
||||||
|
2. Select the **Baccarat** scheme
|
||||||
|
3. Build and run on a simulator or device
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
The app supports:
|
||||||
|
- English (en)
|
||||||
|
- Spanish - Mexico (es-MX)
|
||||||
|
- French - Canada (fr-CA)
|
||||||
|
|
||||||
|
Strings are managed via String Catalogs (`Localizable.xcstrings`).
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
All UI values are centralized in `DesignConstants.swift`:
|
||||||
|
- Spacing, corner radii, font sizes
|
||||||
|
- Opacity and shadow values
|
||||||
|
- Animation durations
|
||||||
|
- Semantic color definitions
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Full VoiceOver support with meaningful labels
|
||||||
|
- Dynamic Type for scalable text
|
||||||
|
- High-contrast visuals
|
||||||
|
- Accessibility summaries for road map history
|
||||||
|
|
||||||
|
## Why Baccarat?
|
||||||
|
|
||||||
|
- **Lowest house edge** in the casino (1.06% on Banker bet)
|
||||||
|
- **No skill required** — pure chance with simple rules
|
||||||
|
- **Fast-paced** gameplay
|
||||||
|
- **Elegant** and sophisticated atmosphere
|
||||||
|
- **James Bond's favorite** casino game
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is proprietary software. All rights reserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Built with ❤️ using SwiftUI*
|
||||||
|
|
||||||
@ -248,20 +248,92 @@ final class BlackjackEngine {
|
|||||||
|
|
||||||
// MARK: - Basic Strategy Hint
|
// MARK: - Basic Strategy Hint
|
||||||
|
|
||||||
/// Returns the basic strategy recommendation based on BJA chart.
|
/// Returns the basic strategy recommendation based on standard casino strategy cards.
|
||||||
/// Accounts for game settings (surrender, dealer hits soft 17, etc.)
|
/// Accounts for game settings (surrender, dealer hits soft 17, etc.)
|
||||||
|
///
|
||||||
|
/// Basic strategy is the mathematically optimal play for each hand combination.
|
||||||
|
/// This implementation follows the standard multi-deck basic strategy chart.
|
||||||
func getHint(playerHand: BlackjackHand, dealerUpCard: Card) -> String {
|
func getHint(playerHand: BlackjackHand, dealerUpCard: Card) -> String {
|
||||||
let playerValue = playerHand.value
|
let playerValue = playerHand.value
|
||||||
let dealerValue = dealerUpCard.blackjackValue
|
let dealerRank = dealerUpCard.rank
|
||||||
let isSoft = playerHand.isSoft
|
let isSoft = playerHand.isSoft
|
||||||
let canDouble = playerHand.cards.count == 2
|
let canDouble = playerHand.cards.count == 2
|
||||||
let surrenderAvailable = settings.lateSurrender
|
let surrenderAvailable = settings.lateSurrender
|
||||||
let dealerHitsS17 = settings.dealerHitsSoft17
|
let dealerHitsS17 = settings.dealerHitsSoft17
|
||||||
|
|
||||||
// SURRENDER (when available) - check first
|
// Helper: Convert dealer rank to numeric value (Ace = 11 for comparison)
|
||||||
if surrenderAvailable && playerHand.cards.count == 2 {
|
let dealerValue: Int = {
|
||||||
// 16 vs 9, 10, A - Surrender
|
switch dealerRank {
|
||||||
if playerValue == 16 && !isSoft && (dealerValue >= 9 || dealerValue == 1) {
|
case .ace: return 11
|
||||||
|
case .two: return 2
|
||||||
|
case .three: return 3
|
||||||
|
case .four: return 4
|
||||||
|
case .five: return 5
|
||||||
|
case .six: return 6
|
||||||
|
case .seven: return 7
|
||||||
|
case .eight: return 8
|
||||||
|
case .nine: return 9
|
||||||
|
case .ten, .jack, .queen, .king: return 10
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// PAIRS - Check first (before surrender, since splitting Aces/8s is always correct)
|
||||||
|
// This matches standard strategy cards: "Always split Aces and 8s"
|
||||||
|
if playerHand.canSplit {
|
||||||
|
let pairRank = playerHand.cards[0].rank
|
||||||
|
switch pairRank {
|
||||||
|
case .ace:
|
||||||
|
// ALWAYS split Aces - this is one of the most important rules
|
||||||
|
return String(localized: "Split")
|
||||||
|
case .eight:
|
||||||
|
// ALWAYS split 8s - two 8s (16) is the worst hand; two hands starting with 8 is better
|
||||||
|
return String(localized: "Split")
|
||||||
|
case .ten, .jack, .queen, .king:
|
||||||
|
// NEVER split 10s - 20 is too strong to break up
|
||||||
|
return String(localized: "Stand")
|
||||||
|
case .five:
|
||||||
|
// NEVER split 5s - treat as hard 10 and double when favorable
|
||||||
|
if canDouble && dealerValue >= 2 && dealerValue <= 9 {
|
||||||
|
return String(localized: "Double")
|
||||||
|
}
|
||||||
|
return String(localized: "Hit")
|
||||||
|
case .four:
|
||||||
|
// Split 4s only vs 5-6 when DAS allowed, otherwise hit
|
||||||
|
if settings.doubleAfterSplit && (dealerValue == 5 || dealerValue == 6) {
|
||||||
|
return String(localized: "Split")
|
||||||
|
}
|
||||||
|
return String(localized: "Hit")
|
||||||
|
case .two, .three:
|
||||||
|
// Split 2s/3s vs dealer 2-7
|
||||||
|
if dealerValue >= 2 && dealerValue <= 7 {
|
||||||
|
return String(localized: "Split")
|
||||||
|
}
|
||||||
|
return String(localized: "Hit")
|
||||||
|
case .six:
|
||||||
|
// Split 6s vs dealer 2-6
|
||||||
|
if dealerValue >= 2 && dealerValue <= 6 {
|
||||||
|
return String(localized: "Split")
|
||||||
|
}
|
||||||
|
return String(localized: "Hit")
|
||||||
|
case .seven:
|
||||||
|
// Split 7s vs dealer 2-7
|
||||||
|
if dealerValue >= 2 && dealerValue <= 7 {
|
||||||
|
return String(localized: "Split")
|
||||||
|
}
|
||||||
|
return String(localized: "Hit")
|
||||||
|
case .nine:
|
||||||
|
// Split 9s vs 2-6 and 8-9. Stand vs 7, 10, Ace
|
||||||
|
if dealerValue == 7 || dealerValue == 10 || dealerRank == .ace {
|
||||||
|
return String(localized: "Stand")
|
||||||
|
}
|
||||||
|
return String(localized: "Split")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SURRENDER (when available) - check after pairs since splitting 8s beats surrendering 16
|
||||||
|
if surrenderAvailable && playerHand.cards.count == 2 && !playerHand.isSplit {
|
||||||
|
// 16 vs 9, 10, A - Surrender (but NOT a pair of 8s - those should split)
|
||||||
|
if playerValue == 16 && !isSoft && (dealerValue >= 9 || dealerRank == .ace) {
|
||||||
return String(localized: "Surrender")
|
return String(localized: "Surrender")
|
||||||
}
|
}
|
||||||
// 15 vs 10 - Surrender
|
// 15 vs 10 - Surrender
|
||||||
@ -269,46 +341,11 @@ final class BlackjackEngine {
|
|||||||
return String(localized: "Surrender")
|
return String(localized: "Surrender")
|
||||||
}
|
}
|
||||||
// 15 vs A - Surrender (if dealer hits soft 17)
|
// 15 vs A - Surrender (if dealer hits soft 17)
|
||||||
if playerValue == 15 && !isSoft && dealerValue == 1 && dealerHitsS17 {
|
if playerValue == 15 && !isSoft && dealerRank == .ace && dealerHitsS17 {
|
||||||
return String(localized: "Surrender")
|
return String(localized: "Surrender")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PAIRS
|
|
||||||
if playerHand.canSplit {
|
|
||||||
let pairRank = playerHand.cards[0].rank
|
|
||||||
switch pairRank {
|
|
||||||
case .ace:
|
|
||||||
return String(localized: "Split")
|
|
||||||
case .eight:
|
|
||||||
return String(localized: "Split")
|
|
||||||
case .ten, .jack, .queen, .king:
|
|
||||||
return String(localized: "Stand")
|
|
||||||
case .five:
|
|
||||||
// Never split 5s - treat as hard 10
|
|
||||||
return (canDouble && dealerValue <= 9) ? String(localized: "Double") : String(localized: "Hit")
|
|
||||||
case .four:
|
|
||||||
// Split 4s vs 5-6 (if DAS), otherwise hit
|
|
||||||
return (settings.doubleAfterSplit && (dealerValue == 5 || dealerValue == 6))
|
|
||||||
? String(localized: "Split") : String(localized: "Hit")
|
|
||||||
case .two, .three:
|
|
||||||
// Split 2s/3s vs 2-7
|
|
||||||
return dealerValue >= 2 && dealerValue <= 7 ? String(localized: "Split") : String(localized: "Hit")
|
|
||||||
case .six:
|
|
||||||
// Split 6s vs 2-6
|
|
||||||
return dealerValue >= 2 && dealerValue <= 6 ? String(localized: "Split") : String(localized: "Hit")
|
|
||||||
case .seven:
|
|
||||||
// Split 7s vs 2-7
|
|
||||||
return dealerValue >= 2 && dealerValue <= 7 ? String(localized: "Split") : String(localized: "Hit")
|
|
||||||
case .nine:
|
|
||||||
// Split 9s vs 2-6, 8-9. Stand vs 7, 10, A
|
|
||||||
if dealerValue == 7 || dealerValue == 10 || dealerValue == 1 {
|
|
||||||
return String(localized: "Stand")
|
|
||||||
}
|
|
||||||
return String(localized: "Split")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SOFT HANDS (Ace counted as 11)
|
// SOFT HANDS (Ace counted as 11)
|
||||||
if isSoft {
|
if isSoft {
|
||||||
switch playerValue {
|
switch playerValue {
|
||||||
@ -396,20 +433,45 @@ final class BlackjackEngine {
|
|||||||
|
|
||||||
/// Returns the count-adjusted strategy recommendation with deviation explanation.
|
/// Returns the count-adjusted strategy recommendation with deviation explanation.
|
||||||
/// Based on the "Illustrious 18" - the most valuable count-based deviations.
|
/// Based on the "Illustrious 18" - the most valuable count-based deviations.
|
||||||
|
///
|
||||||
|
/// These deviations are used by card counters to improve on basic strategy
|
||||||
|
/// when the true count indicates a significant advantage/disadvantage.
|
||||||
func getCountAdjustedHint(playerHand: BlackjackHand, dealerUpCard: Card) -> String {
|
func getCountAdjustedHint(playerHand: BlackjackHand, dealerUpCard: Card) -> String {
|
||||||
let basicHint = getHint(playerHand: playerHand, dealerUpCard: dealerUpCard)
|
let basicHint = getHint(playerHand: playerHand, dealerUpCard: dealerUpCard)
|
||||||
let tc = Int(trueCount.rounded())
|
let tc = Int(trueCount.rounded())
|
||||||
let playerValue = playerHand.value
|
let playerValue = playerHand.value
|
||||||
let dealerValue = dealerUpCard.blackjackValue
|
let dealerRank = dealerUpCard.rank
|
||||||
let isSoft = playerHand.isSoft
|
let isSoft = playerHand.isSoft
|
||||||
|
let canDouble = playerHand.cards.count == 2
|
||||||
|
|
||||||
|
// Helper: Convert dealer rank to numeric value (Ace = 11)
|
||||||
|
let dealerValue: Int = {
|
||||||
|
switch dealerRank {
|
||||||
|
case .ace: return 11
|
||||||
|
case .two: return 2
|
||||||
|
case .three: return 3
|
||||||
|
case .four: return 4
|
||||||
|
case .five: return 5
|
||||||
|
case .six: return 6
|
||||||
|
case .seven: return 7
|
||||||
|
case .eight: return 8
|
||||||
|
case .nine: return 9
|
||||||
|
case .ten, .jack, .queen, .king: return 10
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Helper to format true count with sign
|
// Helper to format true count with sign
|
||||||
let tcDisplay = tc >= 0 ? "+\(tc)" : "\(tc)"
|
let tcDisplay = tc >= 0 ? "+\(tc)" : "\(tc)"
|
||||||
|
|
||||||
// Check for count-based deviations from basic strategy (Illustrious 18)
|
// Check for count-based deviations from basic strategy (Illustrious 18)
|
||||||
|
// These are ordered by importance/frequency of occurrence
|
||||||
|
|
||||||
|
// Note: Pair deviations ONLY apply when player can actually split
|
||||||
|
// Aces and 8s always split per basic strategy - no count deviation changes this
|
||||||
|
|
||||||
// 16 vs 10: Stand when TC ≥ 0 (basic strategy says Hit)
|
// 16 vs 10: Stand when TC ≥ 0 (basic strategy says Hit)
|
||||||
if playerValue == 16 && !isSoft && dealerValue == 10 {
|
// This is one of the most important deviations
|
||||||
|
if playerValue == 16 && !isSoft && !playerHand.canSplit && dealerValue == 10 {
|
||||||
if tc >= 0 {
|
if tc >= 0 {
|
||||||
return String(localized: "Stand, not Hit (TC \(tcDisplay))")
|
return String(localized: "Stand, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
@ -422,6 +484,9 @@ final class BlackjackEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insurance: Take when TC ≥ +3 (basic strategy says never take insurance)
|
||||||
|
// Note: This is handled in the betting phase, not here
|
||||||
|
|
||||||
// 12 vs 2: Stand when TC ≥ +3 (basic strategy says Hit)
|
// 12 vs 2: Stand when TC ≥ +3 (basic strategy says Hit)
|
||||||
if playerValue == 12 && !isSoft && dealerValue == 2 {
|
if playerValue == 12 && !isSoft && dealerValue == 2 {
|
||||||
if tc >= 3 {
|
if tc >= 3 {
|
||||||
@ -451,41 +516,42 @@ final class BlackjackEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 16 vs 9: Stand when TC ≥ +5 (basic strategy says Hit)
|
// 16 vs 9: Stand when TC ≥ +5 (basic strategy says Hit)
|
||||||
if playerValue == 16 && !isSoft && dealerValue == 9 {
|
if playerValue == 16 && !isSoft && !playerHand.canSplit && dealerValue == 9 {
|
||||||
if tc >= 5 {
|
if tc >= 5 {
|
||||||
return String(localized: "Stand, not Hit (TC \(tcDisplay))")
|
return String(localized: "Stand, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10 vs 10: Double when TC ≥ +4 (basic strategy says Hit)
|
// 10 vs 10: Double when TC ≥ +4 (basic strategy says Hit)
|
||||||
if playerValue == 10 && !isSoft && playerHand.cards.count == 2 && dealerValue == 10 {
|
if playerValue == 10 && !isSoft && canDouble && dealerValue == 10 {
|
||||||
if tc >= 4 {
|
if tc >= 4 {
|
||||||
return String(localized: "Double, not Hit (TC \(tcDisplay))")
|
return String(localized: "Double, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10 vs Ace: Double when TC ≥ +4 (basic strategy says Hit)
|
// 10 vs Ace: Double when TC ≥ +4 (basic strategy says Hit)
|
||||||
if playerValue == 10 && !isSoft && playerHand.cards.count == 2 && dealerValue == 1 {
|
if playerValue == 10 && !isSoft && canDouble && dealerRank == .ace {
|
||||||
if tc >= 4 {
|
if tc >= 4 {
|
||||||
return String(localized: "Double, not Hit (TC \(tcDisplay))")
|
return String(localized: "Double, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9 vs 2: Double when TC ≥ +1 (basic strategy says Hit)
|
// 9 vs 2: Double when TC ≥ +1 (basic strategy says Hit)
|
||||||
if playerValue == 9 && !isSoft && playerHand.cards.count == 2 && dealerValue == 2 {
|
if playerValue == 9 && !isSoft && canDouble && dealerValue == 2 {
|
||||||
if tc >= 1 {
|
if tc >= 1 {
|
||||||
return String(localized: "Double, not Hit (TC \(tcDisplay))")
|
return String(localized: "Double, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9 vs 7: Double when TC ≥ +3 (basic strategy says Hit)
|
// 9 vs 7: Double when TC ≥ +3 (basic strategy says Hit)
|
||||||
if playerValue == 9 && !isSoft && playerHand.cards.count == 2 && dealerValue == 7 {
|
if playerValue == 9 && !isSoft && canDouble && dealerValue == 7 {
|
||||||
if tc >= 3 {
|
if tc >= 3 {
|
||||||
return String(localized: "Double, not Hit (TC \(tcDisplay))")
|
return String(localized: "Double, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pair of 10s vs 5: Split when TC ≥ +5 (basic strategy says Stand)
|
// Pair of 10s vs 5: Split when TC ≥ +5 (basic strategy says Stand)
|
||||||
|
// This is an advanced play - only for high counts
|
||||||
if playerHand.canSplit && playerHand.cards[0].blackjackValue == 10 && dealerValue == 5 {
|
if playerHand.canSplit && playerHand.cards[0].blackjackValue == 10 && dealerValue == 5 {
|
||||||
if tc >= 5 {
|
if tc >= 5 {
|
||||||
return String(localized: "Split, not Stand (TC \(tcDisplay))")
|
return String(localized: "Split, not Stand (TC \(tcDisplay))")
|
||||||
|
|||||||
@ -53,6 +53,13 @@ final class GameState {
|
|||||||
/// Whether to show side bet toast notifications.
|
/// Whether to show side bet toast notifications.
|
||||||
var showSideBetToasts: Bool = false
|
var showSideBetToasts: Bool = false
|
||||||
|
|
||||||
|
/// Whether to show the gameplay hint toast.
|
||||||
|
var showHintToast: Bool = false
|
||||||
|
|
||||||
|
/// Tracks the current hint display session to prevent race conditions.
|
||||||
|
/// When a new hint arrives or is shown, increment this so old dismiss tasks become stale.
|
||||||
|
var hintDisplayID: UUID = UUID()
|
||||||
|
|
||||||
/// Whether a reshuffle notification should be shown.
|
/// Whether a reshuffle notification should be shown.
|
||||||
var showReshuffleNotification: Bool = false
|
var showReshuffleNotification: Bool = false
|
||||||
|
|
||||||
@ -67,6 +74,15 @@ final class GameState {
|
|||||||
/// Index of the hand currently being played.
|
/// Index of the hand currently being played.
|
||||||
private(set) var activeHandIndex: Int = 0
|
private(set) var activeHandIndex: Int = 0
|
||||||
|
|
||||||
|
/// Whether an action is currently being processed (prevents double-tap issues).
|
||||||
|
private(set) var isProcessingAction: Bool = false
|
||||||
|
|
||||||
|
/// Time of the last player action (prevents rapid double-taps).
|
||||||
|
private var lastActionTime: Date = .distantPast
|
||||||
|
|
||||||
|
/// Minimum interval between player actions in seconds.
|
||||||
|
private let actionDebounceInterval: TimeInterval = 0.2
|
||||||
|
|
||||||
/// The active player hand.
|
/// The active player hand.
|
||||||
var activeHand: BlackjackHand? {
|
var activeHand: BlackjackHand? {
|
||||||
guard activeHandIndex < playerHands.count else { return nil }
|
guard activeHandIndex < playerHands.count else { return nil }
|
||||||
@ -177,18 +193,21 @@ final class GameState {
|
|||||||
|
|
||||||
/// Whether the current hand can hit.
|
/// Whether the current hand can hit.
|
||||||
var canHit: Bool {
|
var canHit: Bool {
|
||||||
|
guard !isProcessingAction else { return false }
|
||||||
guard case .playerTurn = currentPhase else { return false }
|
guard case .playerTurn = currentPhase else { return false }
|
||||||
return activeHand?.canHit ?? false
|
return activeHand?.canHit ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the current hand can stand.
|
/// Whether the current hand can stand.
|
||||||
var canStand: Bool {
|
var canStand: Bool {
|
||||||
|
guard !isProcessingAction else { return false }
|
||||||
guard case .playerTurn = currentPhase else { return false }
|
guard case .playerTurn = currentPhase else { return false }
|
||||||
return !(activeHand?.isBusted ?? true)
|
return !(activeHand?.isBusted ?? true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the current hand can double.
|
/// Whether the current hand can double.
|
||||||
var canDouble: Bool {
|
var canDouble: Bool {
|
||||||
|
guard !isProcessingAction else { return false }
|
||||||
guard case .playerTurn = currentPhase else { return false }
|
guard case .playerTurn = currentPhase else { return false }
|
||||||
guard let hand = activeHand else { return false }
|
guard let hand = activeHand else { return false }
|
||||||
return engine.canDoubleDown(hand: hand, balance: balance)
|
return engine.canDoubleDown(hand: hand, balance: balance)
|
||||||
@ -196,6 +215,7 @@ final class GameState {
|
|||||||
|
|
||||||
/// Whether the current hand can split.
|
/// Whether the current hand can split.
|
||||||
var canSplit: Bool {
|
var canSplit: Bool {
|
||||||
|
guard !isProcessingAction else { return false }
|
||||||
guard case .playerTurn = currentPhase else { return false }
|
guard case .playerTurn = currentPhase else { return false }
|
||||||
guard let hand = activeHand else { return false }
|
guard let hand = activeHand else { return false }
|
||||||
let splitCount = playerHands.count - 1
|
let splitCount = playerHands.count - 1
|
||||||
@ -204,14 +224,16 @@ final class GameState {
|
|||||||
|
|
||||||
/// Whether the player can surrender.
|
/// Whether the player can surrender.
|
||||||
var canSurrender: Bool {
|
var canSurrender: Bool {
|
||||||
|
guard !isProcessingAction else { return false }
|
||||||
guard case .playerTurn = currentPhase else { return false }
|
guard case .playerTurn = currentPhase else { return false }
|
||||||
guard let hand = activeHand else { return false }
|
guard let hand = activeHand else { return false }
|
||||||
return engine.canSurrender(hand: hand)
|
return engine.canSurrender(hand: hand)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the game is over (out of money and no active bet).
|
/// Whether the game is over (can't afford to meet minimum bet).
|
||||||
|
/// True when in betting phase and total available chips (balance + current bet) is less than minimum bet.
|
||||||
var isGameOver: Bool {
|
var isGameOver: Bool {
|
||||||
balance < settings.minBet && currentPhase == .betting && currentBet == 0
|
currentPhase == .betting && (balance + currentBet) < settings.minBet
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total rounds played.
|
/// Total rounds played.
|
||||||
@ -438,13 +460,20 @@ final class GameState {
|
|||||||
// Ensure enough cards for a full hand - reshuffle if needed
|
// Ensure enough cards for a full hand - reshuffle if needed
|
||||||
if !engine.canDealNewHand {
|
if !engine.canDealNewHand {
|
||||||
engine.reshuffle()
|
engine.reshuffle()
|
||||||
showReshuffleNotification = true
|
|
||||||
|
|
||||||
|
// Show notification with animation
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||||
|
showReshuffleNotification = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-dismiss after 2 seconds
|
||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(for: .seconds(2))
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||||
showReshuffleNotification = false
|
showReshuffleNotification = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
currentPhase = .dealing
|
currentPhase = .dealing
|
||||||
playerHands = [BlackjackHand(bet: currentBet)]
|
playerHands = [BlackjackHand(bet: currentBet)]
|
||||||
@ -478,7 +507,11 @@ final class GameState {
|
|||||||
evaluateSideBets()
|
evaluateSideBets()
|
||||||
|
|
||||||
// Check for insurance offer (only in American style with hole card)
|
// Check for insurance offer (only in American style with hole card)
|
||||||
if !settings.noHoleCard, let upCard = dealerUpCard, engine.shouldOfferInsurance(dealerUpCard: upCard) {
|
// Skip if user has opted out of insurance prompts
|
||||||
|
if !settings.noHoleCard,
|
||||||
|
!settings.neverAskInsurance,
|
||||||
|
let upCard = dealerUpCard,
|
||||||
|
engine.shouldOfferInsurance(dealerUpCard: upCard) {
|
||||||
currentPhase = .insurance
|
currentPhase = .insurance
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -560,11 +593,26 @@ final class GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Declines insurance and sets the "never ask again" preference.
|
||||||
|
func neverAskInsurance() {
|
||||||
|
settings.neverAskInsurance = true
|
||||||
|
declineInsurance()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Player Actions
|
// MARK: - Player Actions
|
||||||
|
|
||||||
/// Player hits (takes another card).
|
/// Player hits (takes another card).
|
||||||
func hit() async {
|
func hit() async {
|
||||||
guard canHit else { return }
|
guard canHit else { return }
|
||||||
|
|
||||||
|
// Debounce: ignore rapid taps
|
||||||
|
let now = Date()
|
||||||
|
guard now.timeIntervalSince(lastActionTime) >= actionDebounceInterval else { return }
|
||||||
|
lastActionTime = now
|
||||||
|
|
||||||
|
isProcessingAction = true
|
||||||
|
defer { isProcessingAction = false }
|
||||||
|
|
||||||
guard let card = engine.dealCard() else { return }
|
guard let card = engine.dealCard() else { return }
|
||||||
|
|
||||||
playerHands[activeHandIndex].cards.append(card)
|
playerHands[activeHandIndex].cards.append(card)
|
||||||
@ -584,6 +632,14 @@ final class GameState {
|
|||||||
func stand() async {
|
func stand() async {
|
||||||
guard canStand else { return }
|
guard canStand else { return }
|
||||||
|
|
||||||
|
// Debounce: ignore rapid taps
|
||||||
|
let now = Date()
|
||||||
|
guard now.timeIntervalSince(lastActionTime) >= actionDebounceInterval else { return }
|
||||||
|
lastActionTime = now
|
||||||
|
|
||||||
|
isProcessingAction = true
|
||||||
|
defer { isProcessingAction = false }
|
||||||
|
|
||||||
playerHands[activeHandIndex].isStanding = true
|
playerHands[activeHandIndex].isStanding = true
|
||||||
await moveToNextHand()
|
await moveToNextHand()
|
||||||
}
|
}
|
||||||
@ -592,6 +648,14 @@ final class GameState {
|
|||||||
func doubleDown() async {
|
func doubleDown() async {
|
||||||
guard canDouble else { return }
|
guard canDouble else { return }
|
||||||
|
|
||||||
|
// Debounce: ignore rapid taps
|
||||||
|
let now = Date()
|
||||||
|
guard now.timeIntervalSince(lastActionTime) >= actionDebounceInterval else { return }
|
||||||
|
lastActionTime = now
|
||||||
|
|
||||||
|
isProcessingAction = true
|
||||||
|
defer { isProcessingAction = false }
|
||||||
|
|
||||||
let additionalBet = playerHands[activeHandIndex].bet
|
let additionalBet = playerHands[activeHandIndex].bet
|
||||||
balance -= additionalBet
|
balance -= additionalBet
|
||||||
playerHands[activeHandIndex].isDoubledDown = true
|
playerHands[activeHandIndex].isDoubledDown = true
|
||||||
@ -616,6 +680,14 @@ final class GameState {
|
|||||||
func split() async {
|
func split() async {
|
||||||
guard canSplit else { return }
|
guard canSplit else { return }
|
||||||
|
|
||||||
|
// Debounce: ignore rapid taps
|
||||||
|
let now = Date()
|
||||||
|
guard now.timeIntervalSince(lastActionTime) >= actionDebounceInterval else { return }
|
||||||
|
lastActionTime = now
|
||||||
|
|
||||||
|
isProcessingAction = true
|
||||||
|
defer { isProcessingAction = false }
|
||||||
|
|
||||||
let originalHand = playerHands[activeHandIndex]
|
let originalHand = playerHands[activeHandIndex]
|
||||||
let splitCard = originalHand.cards[1]
|
let splitCard = originalHand.cards[1]
|
||||||
|
|
||||||
@ -660,6 +732,14 @@ final class GameState {
|
|||||||
func surrender() async {
|
func surrender() async {
|
||||||
guard canSurrender else { return }
|
guard canSurrender else { return }
|
||||||
|
|
||||||
|
// Debounce: ignore rapid taps
|
||||||
|
let now = Date()
|
||||||
|
guard now.timeIntervalSince(lastActionTime) >= actionDebounceInterval else { return }
|
||||||
|
lastActionTime = now
|
||||||
|
|
||||||
|
isProcessingAction = true
|
||||||
|
defer { isProcessingAction = false }
|
||||||
|
|
||||||
playerHands[activeHandIndex].result = .surrender
|
playerHands[activeHandIndex].result = .surrender
|
||||||
await completeRound()
|
await completeRound()
|
||||||
}
|
}
|
||||||
@ -787,7 +867,7 @@ final class GameState {
|
|||||||
|
|
||||||
// Auto-hide toasts after delay
|
// Auto-hide toasts after delay
|
||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(for: .seconds(3))
|
try? await Task.sleep(for: Design.Toast.duration)
|
||||||
showSideBetToasts = false
|
showSideBetToasts = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -947,15 +1027,22 @@ final class GameState {
|
|||||||
// Check if shoe needs reshuffling
|
// Check if shoe needs reshuffling
|
||||||
if engine.needsReshuffle {
|
if engine.needsReshuffle {
|
||||||
engine.reshuffle()
|
engine.reshuffle()
|
||||||
showReshuffleNotification = true
|
|
||||||
|
|
||||||
// Auto-dismiss after a delay
|
// Show notification after delay so it appears after result banner is dismissed
|
||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(for: .seconds(2))
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||||
|
showReshuffleNotification = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-dismiss after showing for 2 seconds
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||||
showReshuffleNotification = false
|
showReshuffleNotification = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - New Round
|
// MARK: - New Round
|
||||||
|
|
||||||
|
|||||||
@ -105,6 +105,9 @@ final class GameSettings {
|
|||||||
/// Whether insurance is offered.
|
/// Whether insurance is offered.
|
||||||
var insuranceAllowed: Bool = true { didSet { save() } }
|
var insuranceAllowed: Bool = true { didSet { save() } }
|
||||||
|
|
||||||
|
/// Whether to skip the insurance prompt and auto-decline.
|
||||||
|
var neverAskInsurance: Bool = false { didSet { save() } }
|
||||||
|
|
||||||
/// Blackjack payout ratio (1.5 = 3:2, 1.2 = 6:5)
|
/// Blackjack payout ratio (1.5 = 3:2, 1.2 = 6:5)
|
||||||
var blackjackPayout: Double = 1.5 { didSet { save() } }
|
var blackjackPayout: Double = 1.5 { didSet { save() } }
|
||||||
|
|
||||||
@ -237,6 +240,7 @@ final class GameSettings {
|
|||||||
self.noHoleCard = data.noHoleCard
|
self.noHoleCard = data.noHoleCard
|
||||||
self.blackjackPayout = data.blackjackPayout
|
self.blackjackPayout = data.blackjackPayout
|
||||||
self.insuranceAllowed = data.insuranceAllowed
|
self.insuranceAllowed = data.insuranceAllowed
|
||||||
|
self.neverAskInsurance = data.neverAskInsurance
|
||||||
self.sideBetsEnabled = data.sideBetsEnabled
|
self.sideBetsEnabled = data.sideBetsEnabled
|
||||||
self.showAnimations = data.showAnimations
|
self.showAnimations = data.showAnimations
|
||||||
self.dealingSpeed = data.dealingSpeed
|
self.dealingSpeed = data.dealingSpeed
|
||||||
@ -263,6 +267,7 @@ final class GameSettings {
|
|||||||
noHoleCard: noHoleCard,
|
noHoleCard: noHoleCard,
|
||||||
blackjackPayout: blackjackPayout,
|
blackjackPayout: blackjackPayout,
|
||||||
insuranceAllowed: insuranceAllowed,
|
insuranceAllowed: insuranceAllowed,
|
||||||
|
neverAskInsurance: neverAskInsurance,
|
||||||
sideBetsEnabled: sideBetsEnabled,
|
sideBetsEnabled: sideBetsEnabled,
|
||||||
showAnimations: showAnimations,
|
showAnimations: showAnimations,
|
||||||
dealingSpeed: dealingSpeed,
|
dealingSpeed: dealingSpeed,
|
||||||
@ -290,6 +295,7 @@ final class GameSettings {
|
|||||||
noHoleCard = false
|
noHoleCard = false
|
||||||
blackjackPayout = 1.5
|
blackjackPayout = 1.5
|
||||||
insuranceAllowed = true
|
insuranceAllowed = true
|
||||||
|
neverAskInsurance = false
|
||||||
sideBetsEnabled = false
|
sideBetsEnabled = false
|
||||||
showAnimations = true
|
showAnimations = true
|
||||||
dealingSpeed = 1.0
|
dealingSpeed = 1.0
|
||||||
|
|||||||
@ -1057,6 +1057,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Auto-decline when dealer shows Ace" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Auto-decline when dealer shows Ace"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Rechazar automáticamente cuando el crupier muestra As"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Refuser automatiquement quand le croupier montre un As"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Baccarat" : {
|
"Baccarat" : {
|
||||||
"comment" : "The name of a casino game.",
|
"comment" : "The name of a casino game.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -2603,6 +2625,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Don't Ask" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Don't Ask"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "No preguntar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Ne pas demander"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Done" : {
|
"Done" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -3480,28 +3524,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Hint: %@": {
|
|
||||||
"localizations": {
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Hint: %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"es-MX": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Consejo: %@"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fr-CA": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Conseil: %@"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Hit" : {
|
"Hit" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -4458,6 +4480,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"New Round" : {
|
"New Round" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -4961,6 +4984,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Play Again" : {
|
"Play Again" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -5230,6 +5254,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Reset Game" : {
|
||||||
|
"comment" : "Button to reset game balance and reshuffle cards.",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Reset Game"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Reiniciar Juego"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Réinitialiser le Jeu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Reset to Defaults" : {
|
"Reset to Defaults" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -5252,6 +5299,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Restore starting balance and reshuffle" : {
|
||||||
|
"comment" : "Subtitle for Reset Game button explaining what it does.",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Restore starting balance and reshuffle"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Restaurar saldo inicial y barajar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Restaurer le solde initial et remélanger"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Roulette" : {
|
"Roulette" : {
|
||||||
"comment" : "The name of a roulette card.",
|
"comment" : "The name of a roulette card.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -5278,6 +5348,7 @@
|
|||||||
},
|
},
|
||||||
"Round result: %@" : {
|
"Round result: %@" : {
|
||||||
"comment" : "An accessibility label for the round result banner, describing the main hand result.",
|
"comment" : "An accessibility label for the round result banner, describing the main hand result.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -5638,6 +5709,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Show Hint" : {
|
||||||
|
"comment" : "Label for a toolbar button that shows a hint.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Show Hints" : {
|
"Show Hints" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -5772,6 +5847,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Skip Insurance Prompt" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Skip Insurance Prompt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Omitir aviso de seguro"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Ignorer l'invitation d'assurance"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Some games: Dealer hits on 'soft 17' (Ace + 6)." : {
|
"Some games: Dealer hits on 'soft 17' (Ace + 6)." : {
|
||||||
"comment" : "Description of a rule where the dealer must hit on a 'soft 17' (Ace + 6) in some blackjack games.",
|
"comment" : "Description of a rule where the dealer must hit on a 'soft 17' (Ace + 6) in some blackjack games.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -6925,6 +7022,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"You've run out of chips!" : {
|
"You've run out of chips!" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
@ -66,6 +66,7 @@ struct BlackjackSettingsData: PersistableGameData {
|
|||||||
noHoleCard: false,
|
noHoleCard: false,
|
||||||
blackjackPayout: 1.5,
|
blackjackPayout: 1.5,
|
||||||
insuranceAllowed: true,
|
insuranceAllowed: true,
|
||||||
|
neverAskInsurance: false,
|
||||||
sideBetsEnabled: false,
|
sideBetsEnabled: false,
|
||||||
showAnimations: true,
|
showAnimations: true,
|
||||||
dealingSpeed: 1.0,
|
dealingSpeed: 1.0,
|
||||||
@ -90,6 +91,7 @@ struct BlackjackSettingsData: PersistableGameData {
|
|||||||
var noHoleCard: Bool
|
var noHoleCard: Bool
|
||||||
var blackjackPayout: Double
|
var blackjackPayout: Double
|
||||||
var insuranceAllowed: Bool
|
var insuranceAllowed: Bool
|
||||||
|
var neverAskInsurance: Bool
|
||||||
var sideBetsEnabled: Bool
|
var sideBetsEnabled: Bool
|
||||||
var showAnimations: Bool
|
var showAnimations: Bool
|
||||||
var dealingSpeed: Double
|
var dealingSpeed: Double
|
||||||
|
|||||||
@ -62,6 +62,7 @@ enum Design {
|
|||||||
static let hintIconSize: CGFloat = 24
|
static let hintIconSize: CGFloat = 24
|
||||||
static let hintPaddingH: CGFloat = 10
|
static let hintPaddingH: CGFloat = 10
|
||||||
static let hintPaddingV: CGFloat = 10
|
static let hintPaddingV: CGFloat = 10
|
||||||
|
static let hintMinWidth: CGFloat = 90
|
||||||
|
|
||||||
// Hand icons
|
// Hand icons
|
||||||
static let handIconSize: CGFloat = 18
|
static let handIconSize: CGFloat = 18
|
||||||
@ -84,7 +85,7 @@ enum Design {
|
|||||||
|
|
||||||
// Result banner
|
// Result banner
|
||||||
static let resultRowAmountWidth: CGFloat = 70
|
static let resultRowAmountWidth: CGFloat = 70
|
||||||
static let resultRowResultWidth: CGFloat = 150
|
static let resultRowResultWidth: CGFloat = 120
|
||||||
|
|
||||||
// Side bet zones
|
// Side bet zones
|
||||||
static let sideBetLabelFontSize: CGFloat = 13
|
static let sideBetLabelFontSize: CGFloat = 13
|
||||||
@ -109,6 +110,27 @@ enum Design {
|
|||||||
/// Bounce for side bet toast animations
|
/// Bounce for side bet toast animations
|
||||||
static let toastBounce: Double = 0.4
|
static let toastBounce: Double = 0.4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Toast Configuration
|
||||||
|
|
||||||
|
enum Toast {
|
||||||
|
/// Duration all toasts stay visible (in seconds).
|
||||||
|
static let duration: Duration = .seconds(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Card Deal Animation
|
||||||
|
|
||||||
|
enum DealAnimation {
|
||||||
|
/// Horizontal offset for dealer cards (shoe is nearby, less horizontal travel)
|
||||||
|
static let dealerOffsetX: CGFloat = 120
|
||||||
|
/// Vertical offset for dealer cards (small since near top)
|
||||||
|
static let dealerOffsetY: CGFloat = -80
|
||||||
|
|
||||||
|
/// Horizontal offset for player cards (shoe is far away, more horizontal travel)
|
||||||
|
static let playerOffsetX: CGFloat = 180
|
||||||
|
/// Vertical offset for player cards (large since far from top)
|
||||||
|
static let playerOffsetY: CGFloat = -350
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Blackjack App Colors
|
// MARK: - Blackjack App Colors
|
||||||
|
|||||||
@ -19,8 +19,8 @@ struct GameTableView: View {
|
|||||||
@State private var showRules = false
|
@State private var showRules = false
|
||||||
@State private var showStats = false
|
@State private var showStats = false
|
||||||
|
|
||||||
/// Full screen size (measured from TableBackgroundView - stable, doesn't change with content)
|
/// Screen size for card sizing (measured from TableBackgroundView)
|
||||||
@State private var fullScreenSize: CGSize = CGSize(width: 375, height: 667)
|
@State private var screenSize: CGSize = CGSize(width: 375, height: 667)
|
||||||
|
|
||||||
// MARK: - Environment
|
// MARK: - Environment
|
||||||
|
|
||||||
@ -42,19 +42,20 @@ struct GameTableView: View {
|
|||||||
return .infinity
|
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
|
// MARK: - Body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
|
||||||
if let state = gameState {
|
|
||||||
mainGameView(state: state)
|
mainGameView(state: state)
|
||||||
} else {
|
.onAppear {
|
||||||
ProgressView()
|
if gameState == nil {
|
||||||
.task {
|
|
||||||
gameState = GameState(settings: settings)
|
gameState = GameState(settings: settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.sheet(isPresented: $showSettings) {
|
.sheet(isPresented: $showSettings) {
|
||||||
SettingsView(settings: settings, gameState: gameState)
|
SettingsView(settings: settings, gameState: gameState)
|
||||||
}
|
}
|
||||||
@ -62,26 +63,56 @@ struct GameTableView: View {
|
|||||||
RulesHelpView()
|
RulesHelpView()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showStats) {
|
.sheet(isPresented: $showStats) {
|
||||||
if let state = gameState {
|
|
||||||
StatisticsSheetView(state: state)
|
StatisticsSheetView(state: state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Use global debug flag from Design constants
|
// Use global debug flag from Design constants
|
||||||
private var showDebugBorders: Bool { Design.showDebugBorders }
|
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
|
// MARK: - Main Game View
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func mainGameView(state: GameState) -> some View {
|
private func mainGameView(state: GameState) -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Background - measures full screen size (stable)
|
// Background - measures screen size for card sizing
|
||||||
TableBackgroundView()
|
TableBackgroundView()
|
||||||
.onGeometryChange(for: CGSize.self) { proxy in
|
.onGeometryChange(for: CGSize.self) { proxy in
|
||||||
proxy.size
|
proxy.size
|
||||||
} action: { size in
|
} action: { size in
|
||||||
fullScreenSize = size
|
screenSize = size
|
||||||
}
|
}
|
||||||
|
|
||||||
mainContent(state: state)
|
mainContent(state: state)
|
||||||
@ -97,7 +128,7 @@ struct GameTableView: View {
|
|||||||
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,
|
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
|
||||||
onReset: { state.resetGame() },
|
leadingButtons: hintToolbarButtons(for: state),
|
||||||
onSettings: { showSettings = true },
|
onSettings: { showSettings = true },
|
||||||
onHelp: { showRules = true },
|
onHelp: { showRules = true },
|
||||||
onStats: { showStats = true }
|
onStats: { showStats = true }
|
||||||
@ -105,32 +136,19 @@ struct GameTableView: View {
|
|||||||
.frame(maxWidth: maxContentWidth)
|
.frame(maxWidth: maxContentWidth)
|
||||||
.debugBorder(showDebugBorders, color: .cyan, label: "TopBar")
|
.debugBorder(showDebugBorders, color: .cyan, label: "TopBar")
|
||||||
|
|
||||||
// Card count display (when enabled)
|
// Table layout
|
||||||
if settings.showCardCount {
|
|
||||||
CardCountView(
|
|
||||||
runningCount: state.engine.runningCount,
|
|
||||||
trueCount: state.engine.trueCount
|
|
||||||
)
|
|
||||||
.frame(maxWidth: maxContentWidth)
|
|
||||||
.debugBorder(showDebugBorders, color: .mint, label: "CardCount")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reshuffle notification
|
|
||||||
if state.showReshuffleNotification {
|
|
||||||
ReshuffleNotificationView(showCardCount: settings.showCardCount)
|
|
||||||
.frame(maxWidth: maxContentWidth)
|
|
||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Table layout - fills available space
|
|
||||||
BlackjackTableView(
|
BlackjackTableView(
|
||||||
state: state,
|
state: state,
|
||||||
selectedChip: selectedChip,
|
selectedChip: selectedChip,
|
||||||
fullScreenSize: fullScreenSize
|
fullScreenSize: screenSize
|
||||||
)
|
)
|
||||||
.frame(maxWidth: maxContentWidth)
|
.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
|
// 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 {
|
if state.currentPhase == .betting && !state.showResultBanner {
|
||||||
ChipSelectorView(
|
ChipSelectorView(
|
||||||
selectedChip: $selectedChip,
|
selectedChip: $selectedChip,
|
||||||
@ -138,14 +156,7 @@ struct GameTableView: View {
|
|||||||
currentBet: state.minBetForChipSelector,
|
currentBet: state.minBetForChipSelector,
|
||||||
maxBet: state.settings.maxBet
|
maxBet: state.settings.maxBet
|
||||||
)
|
)
|
||||||
.frame(maxWidth: maxContentWidth)
|
|
||||||
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
|
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
|
||||||
.onAppear {
|
|
||||||
Design.debugLog("🎰 Chip selector APPEARED (banner showing: \(state.showResultBanner))")
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
Design.debugLog("🎰 Chip selector DISAPPEARED")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action buttons - minimal spacing during player turn
|
// Action buttons - minimal spacing during player turn
|
||||||
@ -154,12 +165,19 @@ struct GameTableView: View {
|
|||||||
.padding(.bottom, Design.Spacing.small)
|
.padding(.bottom, Design.Spacing.small)
|
||||||
.debugBorder(showDebugBorders, color: .blue, label: "ActionBtns")
|
.debugBorder(showDebugBorders, color: .blue, label: "ActionBtns")
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.zIndex(1)
|
.zIndex(1)
|
||||||
.onChange(of: state.currentPhase) { oldPhase, newPhase in
|
.onChange(of: state.currentPhase) { oldPhase, newPhase in
|
||||||
Design.debugLog("🔄 Phase changed: \(oldPhase) → \(newPhase)")
|
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)
|
// Insurance popup overlay (covers entire screen)
|
||||||
if state.currentPhase == .insurance {
|
if state.currentPhase == .insurance {
|
||||||
Color.clear
|
Color.clear
|
||||||
@ -168,7 +186,8 @@ struct GameTableView: View {
|
|||||||
betAmount: state.currentBet / 2,
|
betAmount: state.currentBet / 2,
|
||||||
balance: state.balance,
|
balance: state.balance,
|
||||||
onTake: { Task { await state.takeInsurance() } },
|
onTake: { Task { await state.takeInsurance() } },
|
||||||
onDecline: { state.declineInsurance() }
|
onDecline: { state.declineInsurance() },
|
||||||
|
onNeverAsk: { state.neverAskInsurance() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
@ -219,6 +238,7 @@ struct GameTableView: View {
|
|||||||
.allowsHitTesting(true)
|
.allowsHitTesting(true)
|
||||||
.zIndex(100)
|
.zIndex(100)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.onChange(of: state.playerHands.count) { oldCount, newCount in
|
.onChange(of: state.playerHands.count) { oldCount, newCount in
|
||||||
Design.debugLog("👥 Player hands count: \(oldCount) → \(newCount)")
|
Design.debugLog("👥 Player hands count: \(oldCount) → \(newCount)")
|
||||||
|
|||||||
@ -3,11 +3,13 @@
|
|||||||
// Blackjack
|
// Blackjack
|
||||||
//
|
//
|
||||||
// Displays the result of a round with breakdown.
|
// Displays the result of a round with breakdown.
|
||||||
|
// Uses the shared ResultBannerView from CasinoKit.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CasinoKit
|
import CasinoKit
|
||||||
|
|
||||||
|
/// Result banner for Blackjack using the shared CasinoKit component.
|
||||||
struct ResultBannerView: View {
|
struct ResultBannerView: View {
|
||||||
let result: RoundResult
|
let result: RoundResult
|
||||||
let currentBalance: Int
|
let currentBalance: Int
|
||||||
@ -15,22 +17,9 @@ struct ResultBannerView: View {
|
|||||||
let onNewRound: () -> Void
|
let onNewRound: () -> Void
|
||||||
let onPlayAgain: () -> Void
|
let onPlayAgain: () -> Void
|
||||||
|
|
||||||
@State private var showContent = false
|
|
||||||
|
|
||||||
// MARK: - Scaled Metrics
|
|
||||||
|
|
||||||
@ScaledMetric(relativeTo: .largeTitle) private var titleFontSize: CGFloat = Design.BaseFontSize.largeTitle
|
|
||||||
@ScaledMetric(relativeTo: .title) private var resultFontSize: CGFloat = Design.BaseFontSize.title
|
|
||||||
@ScaledMetric(relativeTo: .headline) private var amountFontSize: CGFloat = Design.BaseFontSize.xLarge
|
|
||||||
@ScaledMetric(relativeTo: .body) private var buttonFontSize: CGFloat = Design.BaseFontSize.medium
|
|
||||||
|
|
||||||
// MARK: - Computed
|
// MARK: - Computed
|
||||||
|
|
||||||
private var isGameOver: Bool {
|
/// Overall result text based on total winnings
|
||||||
currentBalance < minBet
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Overall result based on total winnings (what the player actually cares about)
|
|
||||||
private var overallResultText: String {
|
private var overallResultText: String {
|
||||||
if result.totalWinnings > 0 {
|
if result.totalWinnings > 0 {
|
||||||
return String(localized: "WIN!")
|
return String(localized: "WIN!")
|
||||||
@ -48,60 +37,35 @@ struct ResultBannerView: View {
|
|||||||
return .blue
|
return .blue
|
||||||
}
|
}
|
||||||
|
|
||||||
private var winningsText: String {
|
|
||||||
if result.totalWinnings > 0 {
|
|
||||||
return "+$\(result.totalWinnings)"
|
|
||||||
} else if result.totalWinnings < 0 {
|
|
||||||
return "-$\(abs(result.totalWinnings))"
|
|
||||||
} else {
|
|
||||||
return "$0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var winningsColor: Color {
|
|
||||||
if result.totalWinnings > 0 { return .green }
|
|
||||||
if result.totalWinnings < 0 { return .red }
|
|
||||||
return .blue
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
CasinoResultBannerView(
|
||||||
// Full screen dark background
|
resultText: overallResultText,
|
||||||
Color.black.opacity(Design.Opacity.strong)
|
resultColor: overallResultColor,
|
||||||
|
totalWinnings: result.totalWinnings,
|
||||||
// Content card
|
currentBalance: currentBalance,
|
||||||
VStack(spacing: Design.Spacing.xLarge) {
|
minBet: minBet,
|
||||||
// Overall result based on total winnings
|
breakdownContent: {
|
||||||
Text(overallResultText)
|
ResultBreakdownCard {
|
||||||
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
|
// Hand results
|
||||||
.foregroundStyle(overallResultColor)
|
|
||||||
|
|
||||||
// Winnings
|
|
||||||
Text(winningsText)
|
|
||||||
.font(.system(size: amountFontSize, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(winningsColor)
|
|
||||||
|
|
||||||
// Breakdown - all hands with amounts for splits
|
|
||||||
VStack(spacing: Design.Spacing.small) {
|
|
||||||
ForEach(result.handResults.indices, id: \.self) { index in
|
ForEach(result.handResults.indices, id: \.self) { index in
|
||||||
let handResult = result.handResults[index]
|
let handResult = result.handResults[index]
|
||||||
let handWinnings = index < result.handWinnings.count ? result.handWinnings[index] : nil
|
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
|
let handLabel = result.handResults.count > 1
|
||||||
? String(localized: "Hand \(index + 1)")
|
? String(localized: "Hand \(index + 1)")
|
||||||
: String(localized: "Main Hand")
|
: String(localized: "Main Hand")
|
||||||
// Show amounts for split hands, or for single hand if there are winnings
|
|
||||||
let showAmount = result.hadSplit && handWinnings != nil
|
let showAmount = result.hadSplit && handWinnings != nil
|
||||||
ResultRow(
|
|
||||||
|
HandResultRow(
|
||||||
label: handLabel,
|
label: handLabel,
|
||||||
result: handResult,
|
result: handResult,
|
||||||
amount: showAmount ? handWinnings : nil
|
amount: showAmount ? handWinnings : nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insurance result
|
||||||
if let insuranceResult = result.insuranceResult {
|
if let insuranceResult = result.insuranceResult {
|
||||||
let showInsAmount = result.insuranceWinnings != 0
|
let showInsAmount = result.insuranceWinnings != 0
|
||||||
ResultRow(
|
HandResultRow(
|
||||||
label: String(localized: "Insurance"),
|
label: String(localized: "Insurance"),
|
||||||
result: insuranceResult,
|
result: insuranceResult,
|
||||||
amount: showInsAmount ? result.insuranceWinnings : nil
|
amount: showInsAmount ? result.insuranceWinnings : nil
|
||||||
@ -127,124 +91,44 @@ struct ResultBannerView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.medium)
|
},
|
||||||
.background(
|
onNewRound: onNewRound,
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
onPlayAgain: onPlayAgain
|
||||||
.fill(Color.white.opacity(Design.Opacity.subtle))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Game over message
|
|
||||||
if isGameOver {
|
|
||||||
VStack(spacing: Design.Spacing.small) {
|
|
||||||
Text(String(localized: "You've run out of chips!"))
|
|
||||||
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
|
||||||
|
|
||||||
Button(action: onPlayAgain) {
|
|
||||||
HStack(spacing: Design.Spacing.small) {
|
|
||||||
Image(systemName: "arrow.counterclockwise")
|
|
||||||
Text(String(localized: "Play Again"))
|
|
||||||
}
|
|
||||||
.font(.system(size: buttonFontSize, weight: .bold))
|
|
||||||
.foregroundStyle(.black)
|
|
||||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
|
||||||
.background(
|
|
||||||
Capsule()
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// New Round button
|
|
||||||
Button(action: onNewRound) {
|
|
||||||
HStack(spacing: Design.Spacing.small) {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
Text(String(localized: "New Round"))
|
|
||||||
}
|
|
||||||
.font(.system(size: buttonFontSize, weight: .bold))
|
|
||||||
.foregroundStyle(.black)
|
|
||||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
|
||||||
.background(
|
|
||||||
Capsule()
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(Design.Spacing.xxLarge)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge)
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.CasinoModal.backgroundLight, Color.CasinoModal.backgroundDark],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge)
|
|
||||||
.strokeBorder(
|
|
||||||
overallResultColor.opacity(Design.Opacity.medium),
|
|
||||||
lineWidth: Design.LineWidth.medium
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.shadow(color: overallResultColor.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXLarge)
|
|
||||||
.frame(maxWidth: CasinoDesign.Size.maxModalWidth)
|
|
||||||
.padding(.horizontal, Design.Spacing.large) // Prevent clipping on sides
|
|
||||||
.scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink)
|
|
||||||
.opacity(showContent ? 1.0 : 0)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) {
|
|
||||||
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(Design.Delay.gameOverSound))
|
|
||||||
SoundManager.shared.play(.gameOver)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.accessibilityElement(children: .contain)
|
|
||||||
.accessibilityLabel(String(localized: "Round result: \(result.mainHandResult.displayText)"))
|
|
||||||
.accessibilityAddTraits(AccessibilityTraits.isModal)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Result Row
|
// MARK: - Hand Result Row
|
||||||
|
|
||||||
struct ResultRow: View {
|
/// Row displaying a single hand result with optional amount.
|
||||||
|
private struct HandResultRow: View {
|
||||||
let label: String
|
let label: String
|
||||||
let result: HandResult
|
let result: HandResult
|
||||||
var amount: Int? = nil
|
var amount: Int? = nil
|
||||||
|
|
||||||
private var showDebugBorders: Bool { Design.showDebugBorders }
|
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = Design.BaseFontSize.medium
|
||||||
|
|
||||||
|
private var statusIcon: String {
|
||||||
|
switch result {
|
||||||
|
case .blackjack, .win, .insuranceWin:
|
||||||
|
return "checkmark.circle.fill"
|
||||||
|
case .lose, .bust, .insuranceLose:
|
||||||
|
return "xmark.circle.fill"
|
||||||
|
case .push:
|
||||||
|
return "arrow.left.arrow.right.circle.fill"
|
||||||
|
case .surrender:
|
||||||
|
return "flag.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var amountText: String? {
|
private var amountText: String? {
|
||||||
guard let amount = amount else { return nil }
|
guard let amount = amount else { return nil }
|
||||||
if amount > 0 {
|
if amount > 0 {
|
||||||
return "+$\(amount)"
|
return "+\(amount)"
|
||||||
} else if amount < 0 {
|
} else if amount < 0 {
|
||||||
return "-$\(abs(amount))"
|
return "\(amount)"
|
||||||
} else {
|
} else {
|
||||||
return "$0"
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,51 +140,56 @@ struct ResultRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
// Status icon
|
||||||
|
Image(systemName: statusIcon)
|
||||||
|
.font(.system(size: fontSize))
|
||||||
|
.foregroundStyle(result.color)
|
||||||
|
|
||||||
|
// Label
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: Design.BaseFontSize.medium))
|
.font(.system(size: fontSize, weight: .medium))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
.foregroundStyle(.white.opacity(Design.Opacity.heavy))
|
||||||
.debugBorder(showDebugBorders, color: .blue, label: "Label")
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer")
|
|
||||||
|
|
||||||
// Show amount if provided
|
// Amount (if provided)
|
||||||
if let amountText = amountText {
|
if let amountText = amountText {
|
||||||
Text(amountText)
|
Text(amountText)
|
||||||
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded))
|
.font(.system(size: fontSize, weight: .semibold, design: .rounded))
|
||||||
.foregroundStyle(amountColor)
|
.foregroundStyle(amountColor)
|
||||||
.frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing)
|
|
||||||
.debugBorder(showDebugBorders, color: .green, label: "Amount")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Result text
|
||||||
Text(result.displayText)
|
Text(result.displayText)
|
||||||
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
|
.font(.system(size: fontSize + 2, weight: .bold))
|
||||||
.foregroundStyle(result.color)
|
.foregroundStyle(result.color)
|
||||||
.frame(width: Design.Size.resultRowResultWidth, alignment: .trailing)
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
||||||
.debugBorder(showDebugBorders, color: .red, label: "Result")
|
|
||||||
}
|
}
|
||||||
.debugBorder(showDebugBorders, color: .white, label: "ResultRow")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Side Bet Result Row
|
// MARK: - Side Bet Result Row
|
||||||
|
|
||||||
struct SideBetResultRow: View {
|
/// Row displaying a side bet result.
|
||||||
|
private struct SideBetResultRow: View {
|
||||||
let label: String
|
let label: String
|
||||||
let resultText: String
|
let resultText: String
|
||||||
let isWin: Bool
|
let isWin: Bool
|
||||||
let amount: Int
|
let amount: Int
|
||||||
|
|
||||||
private var showDebugBorders: Bool { Design.showDebugBorders }
|
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = Design.BaseFontSize.medium
|
||||||
|
|
||||||
|
private var statusIcon: String {
|
||||||
|
isWin ? "checkmark.circle.fill" : "xmark.circle.fill"
|
||||||
|
}
|
||||||
|
|
||||||
private var amountText: String {
|
private var amountText: String {
|
||||||
if amount > 0 {
|
if amount > 0 {
|
||||||
return "+$\(amount)"
|
return "+\(amount)"
|
||||||
} else if amount < 0 {
|
} else if amount < 0 {
|
||||||
return "-$\(abs(amount))"
|
return "\(amount)"
|
||||||
} else {
|
} else {
|
||||||
return "$0"
|
return "$0"
|
||||||
}
|
}
|
||||||
@ -317,32 +206,35 @@ struct SideBetResultRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
// Status icon
|
||||||
|
Image(systemName: statusIcon)
|
||||||
|
.font(.system(size: fontSize))
|
||||||
|
.foregroundStyle(resultColor)
|
||||||
|
|
||||||
|
// Label
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: Design.BaseFontSize.medium))
|
.font(.system(size: fontSize, weight: .medium))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
.foregroundStyle(.white.opacity(Design.Opacity.heavy))
|
||||||
.debugBorder(showDebugBorders, color: .blue, label: "Label")
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer")
|
|
||||||
|
|
||||||
|
// Amount
|
||||||
Text(amountText)
|
Text(amountText)
|
||||||
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded))
|
.font(.system(size: fontSize, weight: .semibold, design: .rounded))
|
||||||
.foregroundStyle(amountColor)
|
.foregroundStyle(amountColor)
|
||||||
.frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing)
|
|
||||||
.debugBorder(showDebugBorders, color: .green, label: "Amount")
|
|
||||||
|
|
||||||
|
// Result text
|
||||||
Text(resultText)
|
Text(resultText)
|
||||||
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
|
.font(.system(size: fontSize + 2, weight: .bold))
|
||||||
.foregroundStyle(resultColor)
|
.foregroundStyle(resultColor)
|
||||||
.frame(width: Design.Size.resultRowResultWidth, alignment: .trailing)
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
||||||
.debugBorder(showDebugBorders, color: .red, label: "Result")
|
|
||||||
}
|
|
||||||
.debugBorder(showDebugBorders, color: .white, label: "SideBetRow")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews
|
||||||
|
|
||||||
#Preview("Single Hand") {
|
#Preview("Single Hand") {
|
||||||
ResultBannerView(
|
ResultBannerView(
|
||||||
@ -416,3 +308,19 @@ struct SideBetResultRow: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview("Game Over") {
|
||||||
|
ResultBannerView(
|
||||||
|
result: RoundResult(
|
||||||
|
handResults: [.bust],
|
||||||
|
handWinnings: [-100],
|
||||||
|
insuranceResult: nil,
|
||||||
|
insuranceWinnings: 0,
|
||||||
|
totalWinnings: -100,
|
||||||
|
wasBlackjack: false
|
||||||
|
),
|
||||||
|
currentBalance: 0,
|
||||||
|
minBet: 10,
|
||||||
|
onNewRound: {},
|
||||||
|
onPlayAgain: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -146,6 +146,15 @@ struct SettingsView: View {
|
|||||||
isOn: $settings.showCardsRemaining,
|
isOn: $settings.showCardsRemaining,
|
||||||
accentColor: accent
|
accentColor: accent
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Divider().background(Color.white.opacity(Design.Opacity.hint))
|
||||||
|
|
||||||
|
SettingsToggle(
|
||||||
|
title: String(localized: "Skip Insurance Prompt"),
|
||||||
|
subtitle: String(localized: "Auto-decline when dealer shows Ace"),
|
||||||
|
isOn: $settings.neverAskInsurance,
|
||||||
|
accentColor: accent
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,7 +213,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
|
|
||||||
if state.persistence.iCloudEnabled {
|
if state.persistence.iCloudEnabled {
|
||||||
Divider()
|
Divider()
|
||||||
@ -227,6 +236,7 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.background(Color.white.opacity(Design.Opacity.subtle))
|
.background(Color.white.opacity(Design.Opacity.subtle))
|
||||||
@ -235,11 +245,13 @@ struct SettingsView: View {
|
|||||||
state.persistence.sync()
|
state.persistence.sync()
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
|
||||||
Text(String(localized: "Sync Now"))
|
Text(String(localized: "Sync Now"))
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
}
|
}
|
||||||
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -257,7 +269,7 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -293,15 +305,44 @@ struct SettingsView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
.background(Color.white.opacity(Design.Opacity.subtle))
|
.background(Color.white.opacity(Design.Opacity.subtle))
|
||||||
|
|
||||||
|
// Reset Game - resets balance, keeps stats
|
||||||
|
Button {
|
||||||
|
state.resetGame()
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(String(localized: "Reset Game"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text(String(localized: "Restore starting balance and reshuffle"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.small))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
.font(.system(size: Design.BaseFontSize.large))
|
||||||
|
.foregroundStyle(Color.Sheet.accent)
|
||||||
|
}
|
||||||
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(Design.Opacity.subtle))
|
||||||
|
|
||||||
|
// Clear All Data - nuclear option
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
showClearDataAlert = true
|
showClearDataAlert = true
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "trash")
|
|
||||||
Text(String(localized: "Clear All Data"))
|
Text(String(localized: "Clear All Data"))
|
||||||
}
|
|
||||||
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.font(.system(size: Design.BaseFontSize.large))
|
||||||
|
}
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -320,6 +361,7 @@ struct SettingsView: View {
|
|||||||
.font(.system(size: Design.BaseFontSize.body))
|
.font(.system(size: Design.BaseFontSize.body))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
}
|
}
|
||||||
|
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ struct BlackjackTableView: View {
|
|||||||
|
|
||||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Computed from stable screen size
|
// MARK: - Computed from stable screen size
|
||||||
|
|
||||||
private var screenWidth: CGFloat { fullScreenSize.width }
|
private var screenWidth: CGFloat { fullScreenSize.width }
|
||||||
@ -31,7 +32,6 @@ struct BlackjackTableView: View {
|
|||||||
|
|
||||||
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium
|
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium
|
||||||
@ScaledMetric(relativeTo: .title) private var valueFontSize: CGFloat = Design.BaseFontSize.xLarge
|
@ScaledMetric(relativeTo: .title) private var valueFontSize: CGFloat = Design.BaseFontSize.xLarge
|
||||||
@ScaledMetric(relativeTo: .caption) private var hintFontSize: CGFloat = Design.BaseFontSize.small
|
|
||||||
|
|
||||||
// MARK: - Dynamic Card Sizing
|
// MARK: - Dynamic Card Sizing
|
||||||
|
|
||||||
@ -48,7 +48,7 @@ struct BlackjackTableView: View {
|
|||||||
/// Card width based on full screen height (stable - doesn't change with content)
|
/// Card width based on full screen height (stable - doesn't change with content)
|
||||||
private var cardWidth: CGFloat {
|
private var cardWidth: CGFloat {
|
||||||
let maxDimension = screenHeight
|
let maxDimension = screenHeight
|
||||||
let percentage: CGFloat = 0.13 // ~10% of screen
|
let percentage: CGFloat = 0.18 // ~10% of screen
|
||||||
return maxDimension * percentage
|
return maxDimension * percentage
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,44 +58,62 @@ struct BlackjackTableView: View {
|
|||||||
cardWidth * -0.55
|
cardWidth * -0.55
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fixed height for the hint area to prevent layout shifts
|
|
||||||
private let hintAreaHeight: CGFloat = 44
|
|
||||||
|
|
||||||
// Use global debug flag from Design constants
|
// Use global debug flag from Design constants
|
||||||
private var showDebugBorders: Bool { Design.showDebugBorders }
|
private var showDebugBorders: Bool { Design.showDebugBorders }
|
||||||
|
|
||||||
/// Dynamic spacer height based on screen size.
|
// MARK: - Hint Toast Helper
|
||||||
/// 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.18 // 20% of height above baseline
|
|
||||||
let minSpacing: CGFloat = 10 // Floor for smallest screens
|
|
||||||
let maxSpacing: CGFloat = 150 // Ceiling for largest screens
|
|
||||||
|
|
||||||
let calculated = (screenHeight - baseline) * scale
|
/// Shows the hint toast with auto-dismiss timer.
|
||||||
return min(maxSpacing, max(minSpacing, calculated))
|
private func showHintToastWithTimer(state: GameState) {
|
||||||
|
// Generate new ID to invalidate any pending dismiss tasks
|
||||||
|
let currentID = UUID()
|
||||||
|
state.hintDisplayID = currentID
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: 0) {
|
||||||
// Dealer area
|
// Dealer area
|
||||||
DealerHandView(
|
DealerHandView(
|
||||||
hand: state.dealerHand,
|
hand: state.dealerHand,
|
||||||
showHoleCard: state.shouldShowDealerHoleCard,
|
showHoleCard: state.shouldShowDealerHoleCard,
|
||||||
showCardCount: showCardCount,
|
showCardCount: showCardCount,
|
||||||
|
showAnimations: state.settings.showAnimations,
|
||||||
|
dealingSpeed: state.settings.dealingSpeed,
|
||||||
cardWidth: cardWidth,
|
cardWidth: cardWidth,
|
||||||
cardSpacing: cardSpacing
|
cardSpacing: cardSpacing
|
||||||
)
|
)
|
||||||
.debugBorder(showDebugBorders, color: .red, label: "Dealer")
|
.debugBorder(showDebugBorders, color: .red, label: "Dealer")
|
||||||
|
|
||||||
// Flexible space between dealer and player - scales with screen size
|
// Top spacer
|
||||||
Spacer(minLength: dealerPlayerSpacing)
|
Spacer(minLength: Design.Spacing.small)
|
||||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer \(Int(dealerPlayerSpacing))")
|
.debugBorder(showDebugBorders, color: .yellow, label: "TopSpacer")
|
||||||
|
|
||||||
|
// Card count view centered between dealer and player
|
||||||
|
if showCardCount {
|
||||||
|
CardCountView(
|
||||||
|
runningCount: state.engine.runningCount,
|
||||||
|
trueCount: state.engine.trueCount
|
||||||
|
)
|
||||||
|
.debugBorder(showDebugBorders, color: .mint, label: "CardCount")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom spacer
|
||||||
|
Spacer(minLength: Design.Spacing.small)
|
||||||
|
.debugBorder(showDebugBorders, color: .yellow, label: "BottomSpacer")
|
||||||
|
|
||||||
// Player hands area - only show when there are cards dealt
|
// Player hands area - only show when there are cards dealt
|
||||||
if state.playerHands.first?.cards.isEmpty == false {
|
if state.playerHands.first?.cards.isEmpty == false {
|
||||||
@ -105,8 +123,12 @@ struct BlackjackTableView: View {
|
|||||||
activeHandIndex: state.activeHandIndex,
|
activeHandIndex: state.activeHandIndex,
|
||||||
isPlayerTurn: state.isPlayerTurn,
|
isPlayerTurn: state.isPlayerTurn,
|
||||||
showCardCount: showCardCount,
|
showCardCount: showCardCount,
|
||||||
|
showAnimations: state.settings.showAnimations,
|
||||||
|
dealingSpeed: state.settings.dealingSpeed,
|
||||||
cardWidth: cardWidth,
|
cardWidth: cardWidth,
|
||||||
cardSpacing: cardSpacing
|
cardSpacing: cardSpacing,
|
||||||
|
currentHint: state.currentHint,
|
||||||
|
showHintToast: state.showHintToast
|
||||||
)
|
)
|
||||||
|
|
||||||
// Side bet toasts (positioned on left/right sides to not cover cards)
|
// Side bet toasts (positioned on left/right sides to not cover cards)
|
||||||
@ -139,6 +161,41 @@ struct BlackjackTableView: View {
|
|||||||
.padding(.horizontal, Design.Spacing.small)
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: state.currentHint) { oldHint, newHint in
|
||||||
|
// Show toast when a new hint appears
|
||||||
|
if let hint = newHint, hint != oldHint {
|
||||||
|
showHintToastWithTimer(state: state)
|
||||||
|
} else if newHint == nil {
|
||||||
|
// Hide immediately when no hint
|
||||||
|
state.hintDisplayID = UUID() // Invalidate any pending dismiss tasks
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||||
|
state.showHintToast = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: state.playerHands.count) { _, _ in
|
||||||
|
// Show hint when hands are added (split occurred)
|
||||||
|
if state.currentHint != nil {
|
||||||
|
showHintToastWithTimer(state: state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: state.activeHandIndex) { _, _ in
|
||||||
|
// Show hint when active hand changes (moved to next hand after split)
|
||||||
|
if state.currentHint != nil {
|
||||||
|
showHintToastWithTimer(state: state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: state.activeHand?.cards.count) { _, newCount in
|
||||||
|
// Show hint when a card is added to the active hand (after hit)
|
||||||
|
guard let count = newCount, count > 2, state.currentHint != nil else { return }
|
||||||
|
// Small delay to let card animation settle before showing hint
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(250))
|
||||||
|
if state.currentHint != nil {
|
||||||
|
showHintToastWithTimer(state: state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(.bottom, 5)
|
.padding(.bottom, 5)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.debugBorder(showDebugBorders, color: .green, label: "Player")
|
.debugBorder(showDebugBorders, color: .green, label: "Player")
|
||||||
@ -160,22 +217,12 @@ struct BlackjackTableView: View {
|
|||||||
if let hint = state.bettingHint {
|
if let hint = state.bettingHint {
|
||||||
BettingHintView(hint: hint, trueCount: state.engine.trueCount)
|
BettingHintView(hint: hint, trueCount: state.engine.trueCount)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
.debugBorder(showDebugBorders, color: .purple, label: "BetHint")
|
.debugBorder(showDebugBorders, color: .purple, label: "BetHint")
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Fixed-height hint area to prevent layout shifts during player turn
|
|
||||||
ZStack {
|
|
||||||
if let hint = state.currentHint {
|
|
||||||
HintView(hint: hint)
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: hintAreaHeight)
|
|
||||||
.debugBorder(showDebugBorders, color: .orange, label: "HintArea")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Design.Spacing.large)
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
|
||||||
.debugBorder(showDebugBorders, color: .white, label: "TableView")
|
.debugBorder(showDebugBorders, color: .white, label: "TableView")
|
||||||
.animation(.spring(duration: Design.Animation.springDuration), value: state.currentPhase)
|
.animation(.spring(duration: Design.Animation.springDuration), value: state.currentPhase)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,32 +12,44 @@ struct DealerHandView: View {
|
|||||||
let hand: BlackjackHand
|
let hand: BlackjackHand
|
||||||
let showHoleCard: Bool
|
let showHoleCard: Bool
|
||||||
let showCardCount: Bool
|
let showCardCount: Bool
|
||||||
|
let showAnimations: Bool
|
||||||
|
let dealingSpeed: Double
|
||||||
let cardWidth: CGFloat
|
let cardWidth: CGFloat
|
||||||
let cardSpacing: CGFloat
|
let cardSpacing: CGFloat
|
||||||
|
|
||||||
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
||||||
|
@ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge
|
||||||
|
|
||||||
|
/// Scaled animation duration based on dealing speed.
|
||||||
|
private var animationDuration: Double {
|
||||||
|
Design.Animation.springDuration * dealingSpeed
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
// Label and value
|
// Label and value - fixed height prevents vertical layout shift
|
||||||
HStack(spacing: Design.Spacing.small) {
|
HStack(spacing: Design.Spacing.small) {
|
||||||
Text(String(localized: "DEALER"))
|
Text(String(localized: "DEALER"))
|
||||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
// Show value: full value when hole card visible, otherwise just the face-up card's value
|
// Badge animates in when cards are dealt
|
||||||
if !hand.cards.isEmpty {
|
if !hand.cards.isEmpty {
|
||||||
if showHoleCard {
|
if showHoleCard {
|
||||||
// All cards visible - show total hand value
|
// All cards visible - show total hand value
|
||||||
ValueBadge(value: hand.value, color: Color.Hand.dealer)
|
ValueBadge(value: hand.value, color: Color.Hand.dealer)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
} else {
|
} else {
|
||||||
// Hole card hidden - show only the first (face-up) card's value
|
// Hole card hidden - show only the first (face-up) card's value
|
||||||
ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer)
|
ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cards
|
.frame(minHeight: badgeHeight) // Reserve consistent height
|
||||||
|
.animation(.spring(duration: Design.Animation.springDuration), value: hand.cards.isEmpty)
|
||||||
|
// Cards with result badge overlay (overlay prevents height change)
|
||||||
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
|
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
|
||||||
if hand.cards.isEmpty {
|
if hand.cards.isEmpty {
|
||||||
CardPlaceholderView(width: cardWidth)
|
CardPlaceholderView(width: cardWidth)
|
||||||
@ -56,6 +68,16 @@ struct DealerHandView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.zIndex(Double(index))
|
.zIndex(Double(index))
|
||||||
|
.transition(
|
||||||
|
showAnimations
|
||||||
|
? .asymmetric(
|
||||||
|
insertion: .offset(x: Design.DealAnimation.dealerOffsetX, y: Design.DealAnimation.dealerOffsetY)
|
||||||
|
.combined(with: .opacity)
|
||||||
|
.combined(with: .scale(scale: Design.Scale.slightShrink)),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
)
|
||||||
|
: .identity
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show placeholder for second card in European mode (no hole card)
|
// Show placeholder for second card in European mode (no hole card)
|
||||||
@ -65,18 +87,27 @@ struct DealerHandView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.animation(
|
||||||
// Result badge
|
showAnimations
|
||||||
|
? .spring(duration: animationDuration, bounce: Design.Animation.springBounce)
|
||||||
|
: .none,
|
||||||
|
value: hand.cards.count
|
||||||
|
)
|
||||||
|
.overlay {
|
||||||
|
// Result badge - centered on cards
|
||||||
if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil {
|
if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil {
|
||||||
Text(result)
|
Text(result)
|
||||||
.font(.system(size: labelFontSize, weight: .black))
|
.font(.system(size: labelFontSize, weight: .black))
|
||||||
.foregroundStyle(handResultColor)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
.background(
|
.background(
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(handResultColor.opacity(Design.Opacity.hint))
|
.fill(handResultColor)
|
||||||
|
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
|
||||||
)
|
)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
@ -118,6 +149,8 @@ struct DealerHandView: View {
|
|||||||
hand: BlackjackHand(),
|
hand: BlackjackHand(),
|
||||||
showHoleCard: false,
|
showHoleCard: false,
|
||||||
showCardCount: false,
|
showCardCount: false,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20
|
cardSpacing: -20
|
||||||
)
|
)
|
||||||
@ -134,6 +167,8 @@ struct DealerHandView: View {
|
|||||||
]),
|
]),
|
||||||
showHoleCard: false,
|
showHoleCard: false,
|
||||||
showCardCount: false,
|
showCardCount: false,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20
|
cardSpacing: -20
|
||||||
)
|
)
|
||||||
@ -150,6 +185,8 @@ struct DealerHandView: View {
|
|||||||
]),
|
]),
|
||||||
showHoleCard: true,
|
showHoleCard: true,
|
||||||
showCardCount: true,
|
showCardCount: true,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20
|
cardSpacing: -20
|
||||||
)
|
)
|
||||||
|
|||||||
@ -24,18 +24,24 @@ struct HintView: View {
|
|||||||
Image(systemName: "lightbulb.fill")
|
Image(systemName: "lightbulb.fill")
|
||||||
.font(.system(size: iconSize))
|
.font(.system(size: iconSize))
|
||||||
.foregroundStyle(.yellow)
|
.foregroundStyle(.yellow)
|
||||||
Text(String(localized: "Hint: \(hint)"))
|
Text(hint)
|
||||||
.font(.system(size: fontSize, weight: .medium))
|
.font(.system(size: fontSize, weight: .medium))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
.foregroundStyle(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, paddingH)
|
.padding(.horizontal, paddingH)
|
||||||
.padding(.vertical, paddingV)
|
.padding(.vertical, paddingV)
|
||||||
|
.frame(minWidth: Design.Size.hintMinWidth, alignment: .leading)
|
||||||
.background(
|
.background(
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(Color.black.opacity(Design.Opacity.light))
|
.fill(Color.black.opacity(Design.Opacity.heavy))
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.strokeBorder(Color.yellow.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
.accessibilityLabel(String(localized: "Hint"))
|
.accessibilityLabel(String(localized: "Hint"))
|
||||||
.accessibilityValue(hint)
|
.accessibilityValue(hint)
|
||||||
|
|||||||
@ -13,6 +13,9 @@ struct InsurancePopupView: View {
|
|||||||
let balance: Int
|
let balance: Int
|
||||||
let onTake: () -> Void
|
let onTake: () -> Void
|
||||||
let onDecline: () -> Void
|
let onDecline: () -> Void
|
||||||
|
let onNeverAsk: () -> Void
|
||||||
|
|
||||||
|
@State private var showContent = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@ -45,6 +48,7 @@ struct InsurancePopupView: View {
|
|||||||
.padding(.bottom, Design.Spacing.small)
|
.padding(.bottom, Design.Spacing.small)
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
HStack(spacing: Design.Spacing.large) {
|
HStack(spacing: Design.Spacing.large) {
|
||||||
// Decline button
|
// Decline button
|
||||||
Button(action: onDecline) {
|
Button(action: onDecline) {
|
||||||
@ -80,6 +84,16 @@ struct InsurancePopupView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't Ask Again button
|
||||||
|
Button(action: onNeverAsk) {
|
||||||
|
Text(String(localized: "Don't Ask"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.small, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.xLarge)
|
.padding(Design.Spacing.xLarge)
|
||||||
.background(
|
.background(
|
||||||
@ -91,6 +105,13 @@ struct InsurancePopupView: View {
|
|||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge)
|
RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge)
|
||||||
.strokeBorder(Color.yellow.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
.strokeBorder(Color.yellow.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
||||||
)
|
)
|
||||||
|
.scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink)
|
||||||
|
.opacity(showContent ? 1.0 : 0)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) {
|
||||||
|
showContent = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityAddTraits(.isModal)
|
.accessibilityAddTraits(.isModal)
|
||||||
@ -104,7 +125,8 @@ struct InsurancePopupView: View {
|
|||||||
betAmount: 500,
|
betAmount: 500,
|
||||||
balance: 4500,
|
balance: 4500,
|
||||||
onTake: {},
|
onTake: {},
|
||||||
onDecline: {}
|
onDecline: {},
|
||||||
|
onNeverAsk: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,7 +135,8 @@ struct InsurancePopupView: View {
|
|||||||
betAmount: 500,
|
betAmount: 500,
|
||||||
balance: 200,
|
balance: 200,
|
||||||
onTake: {},
|
onTake: {},
|
||||||
onDecline: {}
|
onDecline: {},
|
||||||
|
onNeverAsk: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,9 +16,17 @@ struct PlayerHandsView: View {
|
|||||||
let activeHandIndex: Int
|
let activeHandIndex: Int
|
||||||
let isPlayerTurn: Bool
|
let isPlayerTurn: Bool
|
||||||
let showCardCount: Bool
|
let showCardCount: Bool
|
||||||
|
let showAnimations: Bool
|
||||||
|
let dealingSpeed: Double
|
||||||
let cardWidth: CGFloat
|
let cardWidth: CGFloat
|
||||||
let cardSpacing: CGFloat
|
let cardSpacing: CGFloat
|
||||||
|
|
||||||
|
/// Current hint to display (shown on active hand only).
|
||||||
|
let currentHint: String?
|
||||||
|
|
||||||
|
/// Whether the hint toast should be visible.
|
||||||
|
let showHintToast: Bool
|
||||||
|
|
||||||
/// Total card count across all hands - used to trigger scroll when hitting
|
/// Total card count across all hands - used to trigger scroll when hitting
|
||||||
private var totalCardCount: Int {
|
private var totalCardCount: Int {
|
||||||
hands.reduce(0) { $0 + $1.cards.count }
|
hands.reduce(0) { $0 + $1.cards.count }
|
||||||
@ -32,14 +40,20 @@ struct PlayerHandsView: View {
|
|||||||
// Visual order: Hand 3, Hand 2, Hand 1 (left to right)
|
// Visual order: Hand 3, Hand 2, Hand 1 (left to right)
|
||||||
// Play order: Hand 1 played first (rightmost), then Hand 2, etc.
|
// Play order: Hand 1 played first (rightmost), then Hand 2, etc.
|
||||||
ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in
|
ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in
|
||||||
|
let isActiveHand = index == activeHandIndex && isPlayerTurn
|
||||||
PlayerHandView(
|
PlayerHandView(
|
||||||
hand: hand,
|
hand: hand,
|
||||||
isActive: index == activeHandIndex && isPlayerTurn,
|
isActive: isActiveHand,
|
||||||
showCardCount: showCardCount,
|
showCardCount: showCardCount,
|
||||||
|
showAnimations: showAnimations,
|
||||||
|
dealingSpeed: dealingSpeed,
|
||||||
// Hand numbers: rightmost (index 0) is Hand 1, played first
|
// Hand numbers: rightmost (index 0) is Hand 1, played first
|
||||||
handNumber: hands.count > 1 ? index + 1 : nil,
|
handNumber: hands.count > 1 ? index + 1 : nil,
|
||||||
cardWidth: cardWidth,
|
cardWidth: cardWidth,
|
||||||
cardSpacing: cardSpacing
|
cardSpacing: cardSpacing,
|
||||||
|
// Only show hint on the active hand
|
||||||
|
currentHint: isActiveHand ? currentHint : nil,
|
||||||
|
showHintToast: isActiveHand && showHintToast
|
||||||
)
|
)
|
||||||
.id(hand.id)
|
.id(hand.id)
|
||||||
.transition(.scale.combined(with: .opacity))
|
.transition(.scale.combined(with: .opacity))
|
||||||
@ -85,10 +99,23 @@ struct PlayerHandView: View {
|
|||||||
let hand: BlackjackHand
|
let hand: BlackjackHand
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
let showCardCount: Bool
|
let showCardCount: Bool
|
||||||
|
let showAnimations: Bool
|
||||||
|
let dealingSpeed: Double
|
||||||
let handNumber: Int?
|
let handNumber: Int?
|
||||||
let cardWidth: CGFloat
|
let cardWidth: CGFloat
|
||||||
let cardSpacing: CGFloat
|
let cardSpacing: CGFloat
|
||||||
|
|
||||||
|
/// Current hint to display on this hand.
|
||||||
|
let currentHint: String?
|
||||||
|
|
||||||
|
/// Whether the hint toast should be visible.
|
||||||
|
let showHintToast: Bool
|
||||||
|
|
||||||
|
/// Scaled animation duration based on dealing speed.
|
||||||
|
private var animationDuration: Double {
|
||||||
|
Design.Animation.springDuration * dealingSpeed
|
||||||
|
}
|
||||||
|
|
||||||
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
||||||
@ScaledMetric(relativeTo: .caption) private var handNumberSize: CGFloat = Design.Size.handNumberFontSize
|
@ScaledMetric(relativeTo: .caption) private var handNumberSize: CGFloat = Design.Size.handNumberFontSize
|
||||||
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.Size.handIconSize
|
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.Size.handIconSize
|
||||||
@ -115,9 +142,25 @@ struct PlayerHandView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.zIndex(Double(index))
|
.zIndex(Double(index))
|
||||||
|
.transition(
|
||||||
|
showAnimations
|
||||||
|
? .asymmetric(
|
||||||
|
insertion: .offset(x: Design.DealAnimation.playerOffsetX, y: Design.DealAnimation.playerOffsetY)
|
||||||
|
.combined(with: .opacity)
|
||||||
|
.combined(with: .scale(scale: Design.Scale.slightShrink)),
|
||||||
|
removal: .scale.combined(with: .opacity)
|
||||||
|
)
|
||||||
|
: .identity
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.animation(
|
||||||
|
showAnimations
|
||||||
|
? .spring(duration: animationDuration, bounce: Design.Animation.springBounce)
|
||||||
|
: .none,
|
||||||
|
value: hand.cards.count
|
||||||
|
)
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
.background(
|
.background(
|
||||||
@ -132,7 +175,7 @@ struct PlayerHandView: View {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.overlay {
|
.overlay {
|
||||||
// Result badge - centered on cards
|
// Result badge - centered on cards (takes priority over hint)
|
||||||
if let result = hand.result {
|
if let result = hand.result {
|
||||||
Text(result.displayText)
|
Text(result.displayText)
|
||||||
.font(.system(size: labelFontSize, weight: .black))
|
.font(.system(size: labelFontSize, weight: .black))
|
||||||
@ -145,6 +188,10 @@ struct PlayerHandView: View {
|
|||||||
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
|
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
|
||||||
)
|
)
|
||||||
.transition(.scale.combined(with: .opacity))
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
} else if showHintToast, let hint = currentHint {
|
||||||
|
// Hint toast - centered on active hand (only when no result)
|
||||||
|
HintView(hint: hint)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
@ -217,8 +264,12 @@ struct PlayerHandView: View {
|
|||||||
activeHandIndex: 0,
|
activeHandIndex: 0,
|
||||||
isPlayerTurn: true,
|
isPlayerTurn: true,
|
||||||
showCardCount: false,
|
showCardCount: false,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20
|
cardSpacing: -20,
|
||||||
|
currentHint: nil,
|
||||||
|
showHintToast: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -234,8 +285,12 @@ struct PlayerHandView: View {
|
|||||||
activeHandIndex: 0,
|
activeHandIndex: 0,
|
||||||
isPlayerTurn: true,
|
isPlayerTurn: true,
|
||||||
showCardCount: false,
|
showCardCount: false,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20
|
cardSpacing: -20,
|
||||||
|
currentHint: "Hit",
|
||||||
|
showHintToast: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -265,8 +320,12 @@ struct PlayerHandView: View {
|
|||||||
activeHandIndex: 1,
|
activeHandIndex: 1,
|
||||||
isPlayerTurn: true,
|
isPlayerTurn: true,
|
||||||
showCardCount: true,
|
showCardCount: true,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20
|
cardSpacing: -20,
|
||||||
|
currentHint: "Stand",
|
||||||
|
showHintToast: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
Blackjack/README.md
Normal file
141
Blackjack/README.md
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# Blackjack 21 - Casino Card Game
|
||||||
|
|
||||||
|
A feature-rich Blackjack app for iOS built with SwiftUI. Master basic strategy, learn card counting, and enjoy an authentic casino experience — all without risking real money.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🎰 Authentic Casino Gameplay
|
||||||
|
- Full Blackjack gameplay with all standard actions: Hit, Stand, Double Down, Split, Surrender
|
||||||
|
- Insurance betting when dealer shows an Ace
|
||||||
|
- Realistic multi-deck shoe with configurable penetration
|
||||||
|
- Animated card dealing with sound effects and haptic feedback
|
||||||
|
- Reshuffle notifications when the cut card is reached
|
||||||
|
|
||||||
|
### 🃏 Multiple Rule Variations
|
||||||
|
| Style | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| **Vegas Strip** | 6 decks, dealer stands on soft 17, double after split, 3:2 blackjack |
|
||||||
|
| **Atlantic City** | 8 decks, late surrender, re-split aces allowed |
|
||||||
|
| **European** | No hole card (dealer gets second card after player acts) |
|
||||||
|
| **Custom** | Configure every rule to your preference |
|
||||||
|
|
||||||
|
### 💰 Side Bets
|
||||||
|
Optional side bets for extra excitement:
|
||||||
|
|
||||||
|
**Perfect Pairs** — Bet on your first two cards forming a pair:
|
||||||
|
- Mixed Pair (different colors): 6:1
|
||||||
|
- Colored Pair (same color): 12:1
|
||||||
|
- Perfect Pair (same suit): 25:1
|
||||||
|
|
||||||
|
**21+3** — Poker hand from your two cards + dealer's upcard:
|
||||||
|
- Flush: 5:1
|
||||||
|
- Straight: 10:1
|
||||||
|
- Three of a Kind: 30:1
|
||||||
|
- Straight Flush: 40:1
|
||||||
|
- Suited Trips: 100:1
|
||||||
|
|
||||||
|
### 📚 Basic Strategy Hints
|
||||||
|
- Real-time strategy suggestions for every hand
|
||||||
|
- Accounts for game rules (surrender availability, dealer hits soft 17, etc.)
|
||||||
|
- Learn the mathematically optimal play for every situation
|
||||||
|
|
||||||
|
### 🎓 Card Counting Trainer
|
||||||
|
Professional-grade tools for learning the Hi-Lo system:
|
||||||
|
- **Running Count** — Live count as cards are dealt
|
||||||
|
- **True Count** — Adjusted for remaining decks
|
||||||
|
- **Betting Hints** — Recommendations based on count advantage
|
||||||
|
- **Illustrious 18** — Count-adjusted strategy deviations
|
||||||
|
|
||||||
|
### 📊 Statistics Tracking
|
||||||
|
- Win rate, blackjack count, bust rate
|
||||||
|
- Session net profit/loss
|
||||||
|
- Biggest wins and losses
|
||||||
|
- Complete round history
|
||||||
|
|
||||||
|
### ☁️ iCloud Sync
|
||||||
|
- Balance and statistics sync across devices
|
||||||
|
- Settings persist via CloudKit
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Blackjack/
|
||||||
|
├── BaccaratApp.swift # App entry point
|
||||||
|
├── ContentView.swift # Root view
|
||||||
|
├── Engine/
|
||||||
|
│ ├── BlackjackEngine.swift # Core game logic, basic strategy, card counting
|
||||||
|
│ └── GameState.swift # Observable state machine
|
||||||
|
├── Models/
|
||||||
|
│ ├── BetType.swift # Bet type definitions
|
||||||
|
│ ├── GameResult.swift # Hand results and round outcomes
|
||||||
|
│ ├── GameSettings.swift # User preferences and rule configuration
|
||||||
|
│ ├── Hand.swift # BlackjackHand model
|
||||||
|
│ └── SideBet.swift # Side bet types and evaluation
|
||||||
|
├── Storage/
|
||||||
|
│ └── BlackjackGameData.swift # Persistence models
|
||||||
|
├── Theme/
|
||||||
|
│ └── DesignConstants.swift # Design system tokens
|
||||||
|
├── Views/
|
||||||
|
│ ├── Development/ # Dev-only views (branding, icons)
|
||||||
|
│ ├── Game/ # Main game UI components
|
||||||
|
│ ├── Sheets/ # Modal views (settings, stats, rules)
|
||||||
|
│ └── Table/ # Table layout components
|
||||||
|
└── Resources/
|
||||||
|
└── Localizable.xcstrings # Localization strings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **iOS 26.0+** target
|
||||||
|
- **Swift 6.2** with strict concurrency
|
||||||
|
- **SwiftUI** with `@Observable` for state management
|
||||||
|
- **CasinoKit** — Shared package for cards, chips, sounds, and common UI
|
||||||
|
- **CloudKit** — iCloud sync for game data and settings
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Xcode 16.0+
|
||||||
|
- iOS 26.0+ deployment target
|
||||||
|
- Swift 6.2+
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Open `CasinoGames.xcworkspace` in Xcode
|
||||||
|
2. Select the **Blackjack** scheme
|
||||||
|
3. Build and run on a simulator or device
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
The app supports:
|
||||||
|
- English (en)
|
||||||
|
- Spanish - Mexico (es-MX)
|
||||||
|
- French - Canada (fr-CA)
|
||||||
|
|
||||||
|
Strings are managed via String Catalogs (`Localizable.xcstrings`).
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
All UI values are centralized in `DesignConstants.swift`:
|
||||||
|
- Spacing, corner radii, font sizes
|
||||||
|
- Opacity and shadow values
|
||||||
|
- Animation durations
|
||||||
|
- Semantic color definitions
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Full VoiceOver support with meaningful labels and hints
|
||||||
|
- Dynamic Type for scalable text
|
||||||
|
- Accessibility announcements for game events
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is proprietary software. All rights reserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Built with ❤️ using SwiftUI*
|
||||||
|
|
||||||
@ -126,7 +126,26 @@ TopBarView(
|
|||||||
balance: 10_500,
|
balance: 10_500,
|
||||||
secondaryInfo: "411",
|
secondaryInfo: "411",
|
||||||
secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill",
|
secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill",
|
||||||
onReset: { resetGame() },
|
onSettings: { showSettings = true },
|
||||||
|
onHelp: { showRules = true },
|
||||||
|
onStats: { showStats = true }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**TopBarButton** - Add custom buttons to the toolbar.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Add game-specific buttons at the front of the toolbar
|
||||||
|
TopBarView(
|
||||||
|
balance: 10_500,
|
||||||
|
leadingButtons: [
|
||||||
|
TopBarButton(icon: "arrow.counterclockwise", accessibilityLabel: "Reset Game") {
|
||||||
|
resetGame()
|
||||||
|
},
|
||||||
|
TopBarButton(icon: "creditcard", accessibilityLabel: "Buy Chips") {
|
||||||
|
showBuyChips = true
|
||||||
|
}
|
||||||
|
],
|
||||||
onSettings: { showSettings = true },
|
onSettings: { showSettings = true },
|
||||||
onHelp: { showRules = true },
|
onHelp: { showRules = true },
|
||||||
onStats: { showStats = true }
|
onStats: { showStats = true }
|
||||||
|
|||||||
@ -27,6 +27,9 @@
|
|||||||
|
|
||||||
// MARK: - Overlays
|
// MARK: - Overlays
|
||||||
// - GameOverView
|
// - GameOverView
|
||||||
|
// - CasinoResultBannerView (animated result banner with staggered animations)
|
||||||
|
// - ResultBreakdownCard (styled container for bet breakdown)
|
||||||
|
// - ResultItemRow (generic result row with icon, label, amount)
|
||||||
|
|
||||||
// MARK: - Table
|
// MARK: - Table
|
||||||
// - TableBackgroundView, FeltPatternView
|
// - TableBackgroundView, FeltPatternView
|
||||||
|
|||||||
@ -165,6 +165,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"+%lld" : {
|
||||||
|
"comment" : "A label displaying the total winnings amount in the result banner. The argument is the total winnings amount.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"$" : {
|
"$" : {
|
||||||
"comment" : "The dollar sign used in the top bar.",
|
"comment" : "The dollar sign used in the top bar.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -916,6 +920,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Game over. You've run out of chips." : {
|
||||||
|
"comment" : "Accessibility label for the result banner when the game is over and the user has run out of chips.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Game progress and statistics are stored locally on your device" : {
|
"Game progress and statistics are stored locally on your device" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -1334,6 +1342,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"New Round" : {
|
||||||
|
"comment" : "A button label that initiates a new round of a casino game.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Nine" : {
|
"Nine" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -1558,6 +1570,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Reset Game" : {
|
"Reset Game" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -1889,6 +1902,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"TOTAL" : {
|
||||||
|
"comment" : "A label displayed alongside the total winnings in the result banner.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Total loss: %lld" : {
|
||||||
|
"comment" : "A string that describes the total loss in a casino game. The argument is the absolute value of the total loss, formatted in U.S. Dollars.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Total winnings: %lld" : {
|
||||||
|
"comment" : "A description of the result text that includes the total winnings. The argument is the total winnings, formatted as currency.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Two" : {
|
"Two" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
@ -37,7 +37,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
public var iCloudAvailable: Bool {
|
public var iCloudAvailable: Bool {
|
||||||
let token = FileManager.default.ubiquityIdentityToken
|
let token = FileManager.default.ubiquityIdentityToken
|
||||||
let available = token != nil
|
let available = token != nil
|
||||||
print("CloudSyncManager: iCloud available = \(available), token = \(String(describing: token))")
|
CasinoDesign.debugLog("CloudSyncManager: iCloud available = \(available), token = \(String(describing: token))")
|
||||||
return available
|
return available
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,30 +120,30 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
|
|
||||||
/// Checks for iCloud data after a brief delay (for fresh installs).
|
/// Checks for iCloud data after a brief delay (for fresh installs).
|
||||||
private func scheduleDelayedCloudCheck() {
|
private func scheduleDelayedCloudCheck() {
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: Scheduling delayed cloud check...")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Scheduling delayed cloud check...")
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// Wait for iCloud to sync (typically takes 1-2 seconds on fresh install)
|
// Wait for iCloud to sync (typically takes 1-2 seconds on fresh install)
|
||||||
try? await Task.sleep(for: .seconds(2))
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: Delayed check - forcing sync...")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Delayed check - forcing sync...")
|
||||||
|
|
||||||
// Force another sync (on main thread to avoid concurrency warning)
|
// Force another sync (on main thread to avoid concurrency warning)
|
||||||
var syncResult = false
|
var syncResult = false
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
syncResult = iCloudStore.synchronize()
|
syncResult = iCloudStore.synchronize()
|
||||||
}
|
}
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: synchronize() returned \(syncResult)")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: synchronize() returned \(syncResult)")
|
||||||
|
|
||||||
// Check what's in the store
|
// Check what's in the store
|
||||||
let allKeys = iCloudStore.dictionaryRepresentation.keys
|
let allKeys = iCloudStore.dictionaryRepresentation.keys
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: iCloud store keys: \(Array(allKeys))")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud store keys: \(Array(allKeys))")
|
||||||
|
|
||||||
// Try loading cloud data again
|
// Try loading cloud data again
|
||||||
if let cloudData = loadCloud() {
|
if let cloudData = loadCloud() {
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: Found cloud data with \(cloudData.roundsPlayed) rounds")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Found cloud data with \(cloudData.roundsPlayed) rounds")
|
||||||
if cloudData.roundsPlayed > data.roundsPlayed {
|
if cloudData.roundsPlayed > data.roundsPlayed {
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: Cloud has more data, updating...")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Cloud has more data, updating...")
|
||||||
data = cloudData
|
data = cloudData
|
||||||
hasCompletedInitialSync = true
|
hasCompletedInitialSync = true
|
||||||
onCloudDataReceived?(cloudData)
|
onCloudDataReceived?(cloudData)
|
||||||
@ -160,11 +160,11 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
userInfo: ["gameIdentifier": T.gameIdentifier]
|
userInfo: ["gameIdentifier": T.gameIdentifier]
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: Local data has same or more rounds")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Local data has same or more rounds")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
hasCompletedInitialSync = true
|
hasCompletedInitialSync = true
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: No cloud data found after delay")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: No cloud data found after delay")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -178,7 +178,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
self.data = dataToSave
|
self.data = dataToSave
|
||||||
|
|
||||||
guard let encoded = try? encoder.encode(dataToSave) else {
|
guard let encoded = try? encoder.encode(dataToSave) else {
|
||||||
print("CloudSyncManager: Failed to encode game data")
|
CasinoDesign.debugLog("CloudSyncManager: Failed to encode game data")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,7 +194,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
syncStatus = "Synced"
|
syncStatus = "Synced"
|
||||||
}
|
}
|
||||||
|
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: Saved (rounds: \(dataToSave.roundsPlayed))")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Saved (rounds: \(dataToSave.roundsPlayed))")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience to update and save in one call.
|
/// Convenience to update and save in one call.
|
||||||
@ -216,31 +216,31 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
|
|
||||||
switch (localData, cloudData) {
|
switch (localData, cloudData) {
|
||||||
case (nil, nil):
|
case (nil, nil):
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: No saved data, using empty")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: No saved data, using empty")
|
||||||
finalData = T.empty
|
finalData = T.empty
|
||||||
|
|
||||||
case (let local?, nil):
|
case (let local?, nil):
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
|
||||||
finalData = local
|
finalData = local
|
||||||
|
|
||||||
case (nil, let cloud?):
|
case (nil, let cloud?):
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud data")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud data")
|
||||||
finalData = cloud
|
finalData = cloud
|
||||||
|
|
||||||
case (let local?, let cloud?):
|
case (let local?, let cloud?):
|
||||||
// Use whichever has more rounds played
|
// Use whichever has more rounds played
|
||||||
if cloud.roundsPlayed > local.roundsPlayed {
|
if cloud.roundsPlayed > local.roundsPlayed {
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud (more progress: \(cloud.roundsPlayed) vs \(local.roundsPlayed))")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud (more progress: \(cloud.roundsPlayed) vs \(local.roundsPlayed))")
|
||||||
finalData = cloud
|
finalData = cloud
|
||||||
// Update local with cloud data
|
// Update local with cloud data
|
||||||
if let encoded = try? encoder.encode(cloud) {
|
if let encoded = try? encoder.encode(cloud) {
|
||||||
UserDefaults.standard.set(encoded, forKey: localKey)
|
UserDefaults.standard.set(encoded, forKey: localKey)
|
||||||
}
|
}
|
||||||
} else if local.lastModified > cloud.lastModified {
|
} else if local.lastModified > cloud.lastModified {
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: Using local (newer: \(local.lastModified))")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using local (newer: \(local.lastModified))")
|
||||||
finalData = local
|
finalData = local
|
||||||
} else {
|
} else {
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
|
||||||
finalData = local
|
finalData = local
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -305,7 +305,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
switch reason {
|
switch reason {
|
||||||
case NSUbiquitousKeyValueStoreServerChange,
|
case NSUbiquitousKeyValueStoreServerChange,
|
||||||
NSUbiquitousKeyValueStoreInitialSyncChange:
|
NSUbiquitousKeyValueStoreInitialSyncChange:
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: Data changed from another device")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Data changed from another device")
|
||||||
syncStatus = "Received update"
|
syncStatus = "Received update"
|
||||||
|
|
||||||
// Reload and notify
|
// Reload and notify
|
||||||
@ -327,11 +327,11 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case NSUbiquitousKeyValueStoreQuotaViolationChange:
|
case NSUbiquitousKeyValueStoreQuotaViolationChange:
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: iCloud quota exceeded")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud quota exceeded")
|
||||||
syncStatus = "Storage full"
|
syncStatus = "Storage full"
|
||||||
|
|
||||||
case NSUbiquitousKeyValueStoreAccountChange:
|
case NSUbiquitousKeyValueStoreAccountChange:
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: iCloud account changed")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud account changed")
|
||||||
syncStatus = "Account changed"
|
syncStatus = "Account changed"
|
||||||
// Reload with new account
|
// Reload with new account
|
||||||
data = load()
|
data = load()
|
||||||
@ -355,7 +355,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
|
|
||||||
data = T.empty
|
data = T.empty
|
||||||
syncStatus = "Data cleared"
|
syncStatus = "Data cleared"
|
||||||
print("CloudSyncManager[\(T.gameIdentifier)]: All data cleared")
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: All data cleared")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,20 @@ import SwiftUI
|
|||||||
/// Shared design constants for casino game components.
|
/// Shared design constants for casino game components.
|
||||||
public enum CasinoDesign {
|
public enum CasinoDesign {
|
||||||
|
|
||||||
|
// MARK: - Debug
|
||||||
|
|
||||||
|
/// Set to true to enable debug logging in CasinoKit.
|
||||||
|
public static let showDebugLogs = false
|
||||||
|
|
||||||
|
/// Logs a message only in debug builds when `showDebugLogs` is enabled.
|
||||||
|
public static func debugLog(_ message: String) {
|
||||||
|
#if DEBUG
|
||||||
|
if showDebugLogs {
|
||||||
|
print(message)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Spacing
|
// MARK: - Spacing
|
||||||
|
|
||||||
public enum Spacing {
|
public enum Spacing {
|
||||||
@ -147,6 +161,9 @@ public enum CasinoDesign {
|
|||||||
/// Checkmark size.
|
/// Checkmark size.
|
||||||
public static let checkmark: CGFloat = 22
|
public static let checkmark: CGFloat = 22
|
||||||
|
|
||||||
|
/// Minimum height for tappable action rows (Apple HIG: 44pt minimum touch target).
|
||||||
|
public static let actionRowMinHeight: CGFloat = 44
|
||||||
|
|
||||||
/// Common button dimensions.
|
/// Common button dimensions.
|
||||||
public static let actionButtonHeight: CGFloat = 50
|
public static let actionButtonHeight: CGFloat = 50
|
||||||
public static var actionButtonMinWidth: CGFloat {
|
public static var actionButtonMinWidth: CGFloat {
|
||||||
|
|||||||
@ -7,6 +7,31 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A button configuration for the top bar toolbar.
|
||||||
|
public struct TopBarButton: Identifiable {
|
||||||
|
public let id = UUID()
|
||||||
|
|
||||||
|
/// The SF Symbol icon name.
|
||||||
|
public let icon: String
|
||||||
|
|
||||||
|
/// The accessibility label for VoiceOver.
|
||||||
|
public let accessibilityLabel: String
|
||||||
|
|
||||||
|
/// The action to perform when tapped.
|
||||||
|
public let action: () -> Void
|
||||||
|
|
||||||
|
/// Creates a toolbar button configuration.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - icon: SF Symbol name for the button icon.
|
||||||
|
/// - accessibilityLabel: VoiceOver label for the button.
|
||||||
|
/// - action: Closure to execute when tapped.
|
||||||
|
public init(icon: String, accessibilityLabel: String, action: @escaping () -> Void) {
|
||||||
|
self.icon = icon
|
||||||
|
self.accessibilityLabel = accessibilityLabel
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A top bar showing balance and customizable toolbar buttons.
|
/// A top bar showing balance and customizable toolbar buttons.
|
||||||
public struct TopBarView: View {
|
public struct TopBarView: View {
|
||||||
/// The current balance to display.
|
/// The current balance to display.
|
||||||
@ -18,11 +43,8 @@ public struct TopBarView: View {
|
|||||||
/// Icon for secondary info.
|
/// Icon for secondary info.
|
||||||
public let secondaryIcon: String?
|
public let secondaryIcon: String?
|
||||||
|
|
||||||
/// Whether to show the reset button.
|
/// Custom buttons to display at the front of the toolbar (before stats/help/settings).
|
||||||
public let showReset: Bool
|
public let leadingButtons: [TopBarButton]
|
||||||
|
|
||||||
/// Action when reset is tapped.
|
|
||||||
public let onReset: (() -> Void)?
|
|
||||||
|
|
||||||
/// Action when settings is tapped.
|
/// Action when settings is tapped.
|
||||||
public let onSettings: (() -> Void)?
|
public let onSettings: (() -> Void)?
|
||||||
@ -45,8 +67,7 @@ public struct TopBarView: View {
|
|||||||
/// - balance: The current balance.
|
/// - balance: The current balance.
|
||||||
/// - secondaryInfo: Optional secondary info text.
|
/// - secondaryInfo: Optional secondary info text.
|
||||||
/// - secondaryIcon: Optional SF Symbol for secondary info.
|
/// - secondaryIcon: Optional SF Symbol for secondary info.
|
||||||
/// - showReset: Whether to show reset button.
|
/// - leadingButtons: Custom buttons to add at the front of the toolbar.
|
||||||
/// - onReset: Reset button action.
|
|
||||||
/// - onSettings: Settings button action.
|
/// - onSettings: Settings button action.
|
||||||
/// - onHelp: Help button action.
|
/// - onHelp: Help button action.
|
||||||
/// - onStats: Stats button action.
|
/// - onStats: Stats button action.
|
||||||
@ -54,8 +75,7 @@ public struct TopBarView: View {
|
|||||||
balance: Int,
|
balance: Int,
|
||||||
secondaryInfo: String? = nil,
|
secondaryInfo: String? = nil,
|
||||||
secondaryIcon: String? = nil,
|
secondaryIcon: String? = nil,
|
||||||
showReset: Bool = true,
|
leadingButtons: [TopBarButton] = [],
|
||||||
onReset: (() -> Void)? = nil,
|
|
||||||
onSettings: (() -> Void)? = nil,
|
onSettings: (() -> Void)? = nil,
|
||||||
onHelp: (() -> Void)? = nil,
|
onHelp: (() -> Void)? = nil,
|
||||||
onStats: (() -> Void)? = nil
|
onStats: (() -> Void)? = nil
|
||||||
@ -63,8 +83,7 @@ public struct TopBarView: View {
|
|||||||
self.balance = balance
|
self.balance = balance
|
||||||
self.secondaryInfo = secondaryInfo
|
self.secondaryInfo = secondaryInfo
|
||||||
self.secondaryIcon = secondaryIcon
|
self.secondaryIcon = secondaryIcon
|
||||||
self.showReset = showReset
|
self.leadingButtons = leadingButtons
|
||||||
self.onReset = onReset
|
|
||||||
self.onSettings = onSettings
|
self.onSettings = onSettings
|
||||||
self.onHelp = onHelp
|
self.onHelp = onHelp
|
||||||
self.onStats = onStats
|
self.onStats = onStats
|
||||||
@ -105,6 +124,12 @@ public struct TopBarView: View {
|
|||||||
|
|
||||||
// Toolbar buttons
|
// Toolbar buttons
|
||||||
HStack(spacing: CasinoDesign.Spacing.medium) {
|
HStack(spacing: CasinoDesign.Spacing.medium) {
|
||||||
|
// Custom leading buttons (game-specific)
|
||||||
|
ForEach(leadingButtons) { button in
|
||||||
|
ToolbarButton(icon: button.icon, action: button.action)
|
||||||
|
.accessibilityLabel(button.accessibilityLabel)
|
||||||
|
}
|
||||||
|
|
||||||
if let onStats = onStats {
|
if let onStats = onStats {
|
||||||
ToolbarButton(icon: "chart.bar.fill", action: onStats)
|
ToolbarButton(icon: "chart.bar.fill", action: onStats)
|
||||||
.accessibilityLabel(String(localized: "Statistics", bundle: .module))
|
.accessibilityLabel(String(localized: "Statistics", bundle: .module))
|
||||||
@ -119,11 +144,6 @@ public struct TopBarView: View {
|
|||||||
ToolbarButton(icon: "gearshape.fill", action: onSettings)
|
ToolbarButton(icon: "gearshape.fill", action: onSettings)
|
||||||
.accessibilityLabel(String(localized: "Settings", bundle: .module))
|
.accessibilityLabel(String(localized: "Settings", bundle: .module))
|
||||||
}
|
}
|
||||||
|
|
||||||
if showReset, let onReset = onReset {
|
|
||||||
ToolbarButton(icon: "arrow.counterclockwise", action: onReset)
|
|
||||||
.accessibilityLabel(String(localized: "Reset Game", bundle: .module))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, CasinoDesign.Spacing.large)
|
.padding(.horizontal, CasinoDesign.Spacing.large)
|
||||||
@ -156,7 +176,9 @@ private struct ToolbarButton: View {
|
|||||||
balance: 10_500,
|
balance: 10_500,
|
||||||
secondaryInfo: "411",
|
secondaryInfo: "411",
|
||||||
secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill",
|
secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill",
|
||||||
onReset: {},
|
leadingButtons: [
|
||||||
|
TopBarButton(icon: "arrow.counterclockwise", accessibilityLabel: "Reset") {}
|
||||||
|
],
|
||||||
onSettings: {},
|
onSettings: {},
|
||||||
onHelp: {},
|
onHelp: {},
|
||||||
onStats: {}
|
onStats: {}
|
||||||
|
|||||||
@ -77,7 +77,7 @@ public struct ChipSelectorView: View {
|
|||||||
.padding(.vertical, CasinoDesign.Spacing.medium)
|
.padding(.vertical, CasinoDesign.Spacing.medium)
|
||||||
.frame(minWidth: geometry.size.width) // Center when content fits
|
.frame(minWidth: geometry.size.width) // Center when content fits
|
||||||
}
|
}
|
||||||
.scrollClipDisabled() // Prevent harsh clipping during scroll/animation
|
.scrollClipDisabled() // Allow visual overflow during scroll/animation
|
||||||
.scrollBounceBehavior(fitsOnScreen ? .basedOnSize : .automatic)
|
.scrollBounceBehavior(fitsOnScreen ? .basedOnSize : .automatic)
|
||||||
}
|
}
|
||||||
.frame(height: CasinoDesign.Size.chipLarge + CasinoDesign.Spacing.medium * 2)
|
.frame(height: CasinoDesign.Size.chipLarge + CasinoDesign.Spacing.medium * 2)
|
||||||
|
|||||||
@ -0,0 +1,514 @@
|
|||||||
|
//
|
||||||
|
// ResultBannerView.swift
|
||||||
|
// CasinoKit
|
||||||
|
//
|
||||||
|
// A reusable animated result banner for casino games.
|
||||||
|
// Each game provides its own breakdown content via a @ViewBuilder closure.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A reusable result banner with staggered animations.
|
||||||
|
///
|
||||||
|
/// This component provides the shared structure for result displays:
|
||||||
|
/// - Animated dark overlay
|
||||||
|
/// - Result text with gradient and glow
|
||||||
|
/// - Game-specific breakdown content (provided via @ViewBuilder)
|
||||||
|
/// - Total winnings display
|
||||||
|
/// - New Round / Play Again button
|
||||||
|
/// - Game over handling
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```swift
|
||||||
|
/// CasinoResultBannerView(
|
||||||
|
/// resultText: "WIN!",
|
||||||
|
/// resultColor: .green,
|
||||||
|
/// totalWinnings: 500,
|
||||||
|
/// currentBalance: 10500,
|
||||||
|
/// minBet: 10,
|
||||||
|
/// breakdownContent: {
|
||||||
|
/// // Your game-specific breakdown views here
|
||||||
|
/// ForEach(betResults) { bet in
|
||||||
|
/// BetResultRow(bet: bet)
|
||||||
|
/// }
|
||||||
|
/// },
|
||||||
|
/// onNewRound: { startNewRound() },
|
||||||
|
/// onPlayAgain: { resetGame() }
|
||||||
|
/// )
|
||||||
|
/// ```
|
||||||
|
public struct CasinoResultBannerView<Content: View>: View {
|
||||||
|
|
||||||
|
// MARK: - Parameters
|
||||||
|
|
||||||
|
/// The result text to display (e.g., "WIN!", "PLAYER WINS", "BLACKJACK!")
|
||||||
|
public let resultText: String
|
||||||
|
|
||||||
|
/// The color associated with the result (green for win, red for loss, etc.)
|
||||||
|
public let resultColor: Color
|
||||||
|
|
||||||
|
/// Total winnings/losses for this round
|
||||||
|
public let totalWinnings: Int
|
||||||
|
|
||||||
|
/// Current balance to determine if game over
|
||||||
|
public let currentBalance: Int
|
||||||
|
|
||||||
|
/// Minimum bet to determine if player can continue
|
||||||
|
public let minBet: Int
|
||||||
|
|
||||||
|
/// Game-specific breakdown content (bet results, hand results, etc.)
|
||||||
|
@ViewBuilder public let breakdownContent: Content
|
||||||
|
|
||||||
|
/// Action when "New Round" is tapped
|
||||||
|
public let onNewRound: () -> Void
|
||||||
|
|
||||||
|
/// Action when "Play Again" is tapped (game over state)
|
||||||
|
public let onPlayAgain: () -> Void
|
||||||
|
|
||||||
|
// MARK: - Animation States
|
||||||
|
|
||||||
|
@State private var showBanner = false
|
||||||
|
@State private var showText = false
|
||||||
|
@State private var showBreakdown = false
|
||||||
|
@State private var showTotal = false
|
||||||
|
@State private var showButton = false
|
||||||
|
|
||||||
|
// MARK: - Environment
|
||||||
|
|
||||||
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
|
// MARK: - Scaled Font Sizes (Dynamic Type)
|
||||||
|
|
||||||
|
@ScaledMetric(relativeTo: .largeTitle) private var resultFontSize: CGFloat = CasinoDesign.BaseFontSize.largeTitle
|
||||||
|
@ScaledMetric(relativeTo: .title3) private var totalFontSize: CGFloat = CasinoDesign.BaseFontSize.xxLarge + CasinoDesign.Spacing.xSmall
|
||||||
|
@ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = CasinoDesign.BaseFontSize.xLarge
|
||||||
|
@ScaledMetric(relativeTo: .title) private var gameOverFontSize: CGFloat = CasinoDesign.BaseFontSize.xxLarge
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
/// Whether the player is out of money and can't continue.
|
||||||
|
private var isGameOver: Bool {
|
||||||
|
currentBalance < minBet
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum width for the banner card on iPad
|
||||||
|
private var maxBannerWidth: CGFloat {
|
||||||
|
horizontalSizeClass == .regular ? CasinoDesign.Size.maxModalWidth : .infinity
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initializer
|
||||||
|
|
||||||
|
/// Creates a result banner view.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - resultText: The main result text to display.
|
||||||
|
/// - resultColor: The color for the result (affects text gradient, border, shadow).
|
||||||
|
/// - totalWinnings: The total amount won or lost this round.
|
||||||
|
/// - currentBalance: The player's current balance (for game over detection).
|
||||||
|
/// - minBet: The minimum bet amount (for game over detection).
|
||||||
|
/// - breakdownContent: Game-specific content showing bet/hand breakdown.
|
||||||
|
/// - onNewRound: Action when "New Round" button is tapped.
|
||||||
|
/// - onPlayAgain: Action when "Play Again" button is tapped (game over state).
|
||||||
|
public init(
|
||||||
|
resultText: String,
|
||||||
|
resultColor: Color,
|
||||||
|
totalWinnings: Int,
|
||||||
|
currentBalance: Int,
|
||||||
|
minBet: Int,
|
||||||
|
@ViewBuilder breakdownContent: () -> Content,
|
||||||
|
onNewRound: @escaping () -> Void,
|
||||||
|
onPlayAgain: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.resultText = resultText
|
||||||
|
self.resultColor = resultColor
|
||||||
|
self.totalWinnings = totalWinnings
|
||||||
|
self.currentBalance = currentBalance
|
||||||
|
self.minBet = minBet
|
||||||
|
self.breakdownContent = breakdownContent()
|
||||||
|
self.onNewRound = onNewRound
|
||||||
|
self.onPlayAgain = onPlayAgain
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Background overlay
|
||||||
|
Color.black.opacity(showBanner ? CasinoDesign.Opacity.medium : 0)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.animation(.easeIn(duration: CasinoDesign.Animation.fadeInDuration), value: showBanner)
|
||||||
|
|
||||||
|
// Banner card
|
||||||
|
VStack(spacing: CasinoDesign.Spacing.medium) {
|
||||||
|
// Game Over header (shown prominently at top when out of chips)
|
||||||
|
if isGameOver {
|
||||||
|
VStack(spacing: CasinoDesign.Spacing.xSmall) {
|
||||||
|
Text(String(localized: "GAME OVER", bundle: .module))
|
||||||
|
.font(.system(size: gameOverFontSize, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
|
||||||
|
Text(String(localized: "You've run out of chips!", bundle: .module))
|
||||||
|
.font(.system(size: CasinoDesign.BaseFontSize.medium, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
|
||||||
|
}
|
||||||
|
.scaleEffect(showText ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
|
||||||
|
.opacity(showText ? CasinoDesign.Scale.normal : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result text with gradient and glow (smaller when game over)
|
||||||
|
Text(resultText)
|
||||||
|
.font(.system(size: isGameOver ? resultFontSize * 0.7 : resultFontSize, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.white, resultColor],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.shadow(color: resultColor.opacity(CasinoDesign.Opacity.heavy), radius: CasinoDesign.Shadow.radiusLarge)
|
||||||
|
.scaleEffect(showText ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
|
||||||
|
.opacity(showText ? CasinoDesign.Scale.normal : 0)
|
||||||
|
|
||||||
|
// Game-specific breakdown content
|
||||||
|
breakdownContent
|
||||||
|
.scaleEffect(showBreakdown ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
|
||||||
|
.opacity(showBreakdown ? CasinoDesign.Scale.normal : 0)
|
||||||
|
|
||||||
|
// Total winnings
|
||||||
|
if totalWinnings != 0 {
|
||||||
|
totalWinningsView
|
||||||
|
.scaleEffect(showTotal ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
|
||||||
|
.opacity(showTotal ? CasinoDesign.Scale.normal : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button section
|
||||||
|
buttonSection
|
||||||
|
.scaleEffect(showButton ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
|
||||||
|
.opacity(showButton ? CasinoDesign.Scale.normal : 0)
|
||||||
|
.padding(.top, CasinoDesign.Spacing.small)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, CasinoDesign.Spacing.xLarge)
|
||||||
|
.padding(.vertical, CasinoDesign.Spacing.xxLarge)
|
||||||
|
.frame(maxWidth: maxBannerWidth)
|
||||||
|
.background(bannerBackground)
|
||||||
|
.shadow(color: resultColor.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusXXLarge)
|
||||||
|
.padding(.horizontal, CasinoDesign.Spacing.large)
|
||||||
|
.scaleEffect(showBanner ? CasinoDesign.Scale.normal : CasinoDesign.Scale.slightShrink)
|
||||||
|
.opacity(showBanner ? CasinoDesign.Scale.normal : 0)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
startAnimations()
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(accessibilityDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subviews
|
||||||
|
|
||||||
|
private var totalWinningsView: some View {
|
||||||
|
HStack(spacing: CasinoDesign.Spacing.small) {
|
||||||
|
Text(String(localized: "TOTAL", bundle: .module))
|
||||||
|
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .bold))
|
||||||
|
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.accent))
|
||||||
|
|
||||||
|
if totalWinnings > 0 {
|
||||||
|
Text("+\(totalWinnings)")
|
||||||
|
.font(.system(size: totalFontSize, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
Text("\(totalWinnings)")
|
||||||
|
.font(.system(size: totalFontSize, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var buttonSection: some View {
|
||||||
|
if isGameOver {
|
||||||
|
// Game Over - show restart button (message is at top)
|
||||||
|
Button {
|
||||||
|
onPlayAgain()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: CasinoDesign.Spacing.small) {
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
Text(String(localized: "Play Again", bundle: .module))
|
||||||
|
}
|
||||||
|
.font(.system(size: buttonFontSize, weight: .bold))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
.padding(.horizontal, CasinoDesign.Spacing.xxxLarge + CasinoDesign.Spacing.small)
|
||||||
|
.padding(.vertical, CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall)
|
||||||
|
.background(goldButtonBackground)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal - New Round button
|
||||||
|
Button {
|
||||||
|
onNewRound()
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: "New Round", bundle: .module))
|
||||||
|
.font(.system(size: buttonFontSize, weight: .bold))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
.padding(.horizontal, CasinoDesign.Spacing.xxxLarge + CasinoDesign.Spacing.small)
|
||||||
|
.padding(.vertical, CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall)
|
||||||
|
.background(goldButtonBackground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var goldButtonBackground: some View {
|
||||||
|
Capsule()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.shadow(color: .yellow.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bannerBackground: some View {
|
||||||
|
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.xxLarge)
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color.CasinoModal.backgroundLight,
|
||||||
|
Color.CasinoModal.backgroundDark
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.xxLarge)
|
||||||
|
.strokeBorder(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
resultColor.opacity(CasinoDesign.Opacity.heavy),
|
||||||
|
resultColor.opacity(CasinoDesign.Opacity.light)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
),
|
||||||
|
lineWidth: CasinoDesign.LineWidth.thick
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Animations
|
||||||
|
|
||||||
|
private func startAnimations() {
|
||||||
|
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce)) {
|
||||||
|
showBanner = true
|
||||||
|
}
|
||||||
|
|
||||||
|
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay1)) {
|
||||||
|
showText = true
|
||||||
|
}
|
||||||
|
|
||||||
|
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay2)) {
|
||||||
|
showBreakdown = true
|
||||||
|
}
|
||||||
|
|
||||||
|
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay3)) {
|
||||||
|
showTotal = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show button after everything else
|
||||||
|
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay3 + CasinoDesign.Animation.staggerDelay1)) {
|
||||||
|
showButton = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play game over sound if out of chips
|
||||||
|
if isGameOver {
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
SoundManager.shared.play(.gameOver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Announce result to VoiceOver users
|
||||||
|
announceResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Accessibility
|
||||||
|
|
||||||
|
private var accessibilityDescription: String {
|
||||||
|
var description = resultText
|
||||||
|
|
||||||
|
if totalWinnings > 0 {
|
||||||
|
description += ". " + String(localized: "Total winnings: \(totalWinnings)", bundle: .module)
|
||||||
|
} else if totalWinnings < 0 {
|
||||||
|
description += ". " + String(localized: "Total loss: \(abs(totalWinnings))", bundle: .module)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isGameOver {
|
||||||
|
description += ". " + String(localized: "Game over. You've run out of chips.", bundle: .module)
|
||||||
|
}
|
||||||
|
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
private func announceResult() {
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
AccessibilityNotification.Announcement(accessibilityDescription).post()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Result Breakdown Card
|
||||||
|
|
||||||
|
/// A styled container for bet/hand breakdown content.
|
||||||
|
/// Provides consistent styling across all casino games.
|
||||||
|
public struct ResultBreakdownCard<Content: View>: View {
|
||||||
|
@ViewBuilder public let content: Content
|
||||||
|
|
||||||
|
public init(@ViewBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(spacing: CasinoDesign.Spacing.xSmall) {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.padding(.horizontal, CasinoDesign.Spacing.medium)
|
||||||
|
.padding(.vertical, CasinoDesign.Spacing.small)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
|
||||||
|
.fill(Color.white.opacity(CasinoDesign.Opacity.verySubtle))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Result Row
|
||||||
|
|
||||||
|
/// A generic row for displaying a result item with label, optional amount, and status.
|
||||||
|
public struct ResultItemRow: View {
|
||||||
|
public let label: String
|
||||||
|
public let statusText: String
|
||||||
|
public let statusColor: Color
|
||||||
|
public var amount: Int?
|
||||||
|
public var statusIcon: String?
|
||||||
|
|
||||||
|
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = CasinoDesign.BaseFontSize.medium
|
||||||
|
|
||||||
|
public init(
|
||||||
|
label: String,
|
||||||
|
statusText: String,
|
||||||
|
statusColor: Color,
|
||||||
|
amount: Int? = nil,
|
||||||
|
statusIcon: String? = nil
|
||||||
|
) {
|
||||||
|
self.label = label
|
||||||
|
self.statusText = statusText
|
||||||
|
self.statusColor = statusColor
|
||||||
|
self.amount = amount
|
||||||
|
self.statusIcon = statusIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
private var amountText: String? {
|
||||||
|
guard let amount = amount else { return nil }
|
||||||
|
if amount > 0 {
|
||||||
|
return "+\(amount)"
|
||||||
|
} else if amount < 0 {
|
||||||
|
return "\(amount)"
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var amountColor: Color {
|
||||||
|
guard let amount = amount else { return .white }
|
||||||
|
if amount > 0 { return .green }
|
||||||
|
if amount < 0 { return .red }
|
||||||
|
return .blue
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack(spacing: CasinoDesign.Spacing.small) {
|
||||||
|
// Status icon (if provided)
|
||||||
|
if let icon = statusIcon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: fontSize))
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: fontSize, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.heavy))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Amount (if provided)
|
||||||
|
if let amountText = amountText {
|
||||||
|
Text(amountText)
|
||||||
|
.font(.system(size: fontSize, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundStyle(amountColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status text
|
||||||
|
Text(statusText)
|
||||||
|
.font(.system(size: fontSize + 2, weight: .bold))
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview("Win Result") {
|
||||||
|
ZStack {
|
||||||
|
Color.CasinoTable.felt
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
CasinoResultBannerView(
|
||||||
|
resultText: "WIN!",
|
||||||
|
resultColor: .green,
|
||||||
|
totalWinnings: 500,
|
||||||
|
currentBalance: 10500,
|
||||||
|
minBet: 10,
|
||||||
|
breakdownContent: {
|
||||||
|
ResultBreakdownCard {
|
||||||
|
ResultItemRow(
|
||||||
|
label: "Main Hand",
|
||||||
|
statusText: "WIN",
|
||||||
|
statusColor: .green,
|
||||||
|
amount: 500,
|
||||||
|
statusIcon: "checkmark.circle.fill"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNewRound: {},
|
||||||
|
onPlayAgain: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Game Over") {
|
||||||
|
ZStack {
|
||||||
|
Color.CasinoTable.felt
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
CasinoResultBannerView(
|
||||||
|
resultText: "LOSE",
|
||||||
|
resultColor: .red,
|
||||||
|
totalWinnings: -100,
|
||||||
|
currentBalance: 0,
|
||||||
|
minBet: 10,
|
||||||
|
breakdownContent: {
|
||||||
|
ResultBreakdownCard {
|
||||||
|
ResultItemRow(
|
||||||
|
label: "Main Hand",
|
||||||
|
statusText: "BUST",
|
||||||
|
statusColor: .red,
|
||||||
|
amount: -100,
|
||||||
|
statusIcon: "xmark.circle.fill"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNewRound: {},
|
||||||
|
onPlayAgain: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -75,7 +75,12 @@ struct GameTableView: View {
|
|||||||
balance: state.balance,
|
balance: state.balance,
|
||||||
secondaryInfo: "\(state.engine.shoe.cardsRemaining)",
|
secondaryInfo: "\(state.engine.shoe.cardsRemaining)",
|
||||||
secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill",
|
secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill",
|
||||||
onReset: { state.resetGame() },
|
leadingButtons: [
|
||||||
|
// Add game-specific buttons here (optional)
|
||||||
|
// TopBarButton(icon: "arrow.counterclockwise", accessibilityLabel: "Reset") {
|
||||||
|
// state.resetGame()
|
||||||
|
// }
|
||||||
|
],
|
||||||
onSettings: { showSettings = true },
|
onSettings: { showSettings = true },
|
||||||
onHelp: { showRules = true },
|
onHelp: { showRules = true },
|
||||||
onStats: { showStats = true }
|
onStats: { showStats = true }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user