Compare commits

...

20 Commits

Author SHA1 Message Date
547f690e3c Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-28 16:00:04 -06:00
545271e9bd Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-28 15:56:26 -06:00
7800d3474d Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-27 13:04:07 -06:00
9ac5320782 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-26 12:23:00 -06:00
645602b2de Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-26 12:07:11 -06:00
d56b0b71d7 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-26 11:59:57 -06:00
2c5b264f9b Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-25 09:51:40 -06:00
a470d8984c Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-25 09:40:37 -06:00
45ad602d9a Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-25 09:39:48 -06:00
98d72d0db8 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-25 09:07:28 -06:00
889e91a8ca Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-24 14:57:22 -06:00
582ed3237d Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-24 14:53:28 -06:00
c358d3b2ae Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-24 14:50:58 -06:00
4d79f08089 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-24 14:01:45 -06:00
2b0f36a12c Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-24 13:57:36 -06:00
71b27b671b Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-24 13:43:14 -06:00
21e5d901c7 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-24 13:27:25 -06:00
2cd2946a80 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-24 12:19:59 -06:00
1dc64ebf69 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-24 11:38:32 -06:00
7234cd718a Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-24 11:27:27 -06:00
32 changed files with 6473 additions and 5232 deletions

View File

@ -2606,6 +2606,7 @@
},
"Play Again" : {
"comment" : "A button label that says \"Play Again\".",
"extractionState" : "stale",
"localizations" : {
"en" : {
"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" : {
"comment" : "A button label that resets game settings to their default values.",
"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" : {
"comment" : "The name of a roulette game.",
"localizations" : {
@ -3496,6 +3543,7 @@
},
"TOTAL" : {
"comment" : "A label displayed next to the total winnings in the result banner.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -3794,6 +3842,7 @@
},
"You've run out of chips!" : {
"comment" : "A message displayed when a player runs out of money in the game over screen.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {

View File

@ -50,6 +50,15 @@ enum Design {
static let labelFontSize: CGFloat = 14
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

View File

@ -135,7 +135,6 @@ struct GameTableView: View {
balance: state.balance,
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.shoe.cardsRemaining)" : nil,
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
onReset: { state.resetGame() },
onSettings: { showSettings = true },
onHelp: { showRules = true },
onStats: { showStats = true }
@ -194,7 +193,9 @@ struct GameTableView: View {
bankerValue: state.bankerHandValue,
playerIsWinner: playerIsWinner,
bankerIsWinner: bankerIsWinner,
isTie: isTie
isTie: isTie,
showAnimations: settings.showAnimations,
dealingSpeed: settings.dealingSpeed
)
.frame(maxWidth: maxContentWidth)
.padding(.horizontal, Design.Spacing.medium)
@ -213,14 +214,13 @@ struct GameTableView: View {
Spacer(minLength: Design.Spacing.xSmall)
// Chip selector
// Chip selector - full width so all chips are tappable
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
currentBet: state.totalBetAmount,
maxBet: state.maxBet
)
.frame(maxWidth: maxContentWidth)
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
Spacer(minLength: Design.Spacing.xSmall)
@ -256,7 +256,6 @@ struct GameTableView: View {
balance: state.balance,
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.shoe.cardsRemaining)" : nil,
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
onReset: { state.resetGame() },
onSettings: { showSettings = true },
onHelp: { showRules = true },
onStats: { showStats = true }
@ -276,7 +275,9 @@ struct GameTableView: View {
bankerValue: state.bankerHandValue,
playerIsWinner: playerIsWinner,
bankerIsWinner: bankerIsWinner,
isTie: isTie
isTie: isTie,
showAnimations: settings.showAnimations,
dealingSpeed: settings.dealingSpeed
)
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
.padding(.horizontal, Design.Spacing.medium)
@ -308,14 +309,13 @@ struct GameTableView: View {
Spacer(minLength: mediumSpacerHeight)
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer4")
// Chip selector (from CasinoKit)
// Chip selector - full width so all chips are tappable
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
currentBet: state.totalBetAmount,
maxBet: state.maxBet
)
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
Spacer(minLength: smallSpacerHeight)

View File

@ -3,12 +3,14 @@
// Baccarat
//
// Animated result banner showing the winner and itemized bet results.
// Uses the shared ResultBannerView from CasinoKit.
//
import SwiftUI
import CasinoKit
/// An animated banner showing the round result with bet breakdown.
/// This is a wrapper around CasinoKit's shared ResultBannerView.
struct ResultBannerView: View {
let result: GameResult
let totalWinnings: Int
@ -20,30 +22,6 @@ struct ResultBannerView: View {
let onNewRound: () -> 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
private var winningBets: [BetResult] {
@ -59,28 +37,13 @@ struct ResultBannerView: View {
}
var body: some View {
ZStack {
// Background overlay
Color.black.opacity(showBanner ? Design.Opacity.medium : 0)
.ignoresSafeArea()
.animation(.easeIn(duration: Design.Animation.fadeInDuration), value: showBanner)
// Banner
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)
CasinoResultBannerView(
resultText: result.displayText,
resultColor: result.color,
totalWinnings: totalWinnings,
currentBalance: currentBalance,
minBet: minBet,
breakdownContent: {
// Pair indicators
if playerHadPair || bankerHadPair {
HStack(spacing: Design.Spacing.large) {
@ -91,8 +54,6 @@ struct ResultBannerView: View {
PairBadge(label: "B PAIR", color: .red)
}
}
.scaleEffect(showBreakdown ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showBreakdown ? Design.Scale.normal : 0)
}
// Bet breakdown
@ -100,204 +61,13 @@ struct ResultBannerView: View {
BetBreakdownView(
winningBets: winningBets,
losingBets: losingBets,
pushBets: pushBets,
fontSize: itemFontSize
pushBets: pushBets
)
.scaleEffect(showBreakdown ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showBreakdown ? Design.Scale.normal : 0)
}
// Total
if totalWinnings != 0 {
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()
}
},
onNewRound: onNewRound,
onPlayAgain: onGameOver
)
}
}
@ -307,10 +77,11 @@ private struct BetBreakdownView: View {
let winningBets: [BetResult]
let losingBets: [BetResult]
let pushBets: [BetResult]
let fontSize: CGFloat
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = Design.BaseFontSize.medium
var body: some View {
VStack(spacing: Design.Spacing.xSmall) {
ResultBreakdownCard {
// Winning bets
ForEach(winningBets) { bet in
BetResultRow(bet: bet, fontSize: fontSize)
@ -326,12 +97,6 @@ private struct BetBreakdownView: View {
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") {
ZStack {

View File

@ -150,7 +150,7 @@ struct SettingsView: View {
}
}
.tint(accent)
.padding(.vertical, Design.Spacing.xSmall)
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
if gameState.iCloudEnabled {
Divider()
@ -173,6 +173,7 @@ struct SettingsView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
Divider()
.background(Color.white.opacity(Design.Opacity.subtle))
@ -181,11 +182,13 @@ struct SettingsView: View {
gameState.syncWithCloud()
} label: {
HStack {
Image(systemName: "arrow.triangle.2.circlepath")
Text(String(localized: "Sync Now"))
Spacer()
Image(systemName: "arrow.triangle.2.circlepath")
}
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(accent)
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
}
}
} else {
@ -203,7 +206,7 @@ struct SettingsView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.padding(.vertical, Design.Spacing.xSmall)
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
}
}
@ -237,15 +240,43 @@ struct SettingsView: View {
Divider()
.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) {
showClearDataAlert = true
} label: {
HStack {
Image(systemName: "trash")
Text(String(localized: "Clear All Data"))
Spacer()
Image(systemName: "trash")
}
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.red)
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
}
}
@ -263,6 +294,7 @@ struct SettingsView: View {
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
}
}

View File

@ -19,6 +19,8 @@ struct CardsDisplayArea: View {
let playerIsWinner: Bool
let bankerIsWinner: Bool
let isTie: Bool
let showAnimations: Bool
let dealingSpeed: Double
// MARK: - State
@ -147,7 +149,9 @@ struct CardsDisplayArea: View {
cards: playerCards,
cardsFaceUp: playerCardsFaceUp,
isWinner: playerIsWinner,
containerWidth: width
containerWidth: width,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed
)
}
.frame(width: width)
@ -175,7 +179,9 @@ struct CardsDisplayArea: View {
cards: bankerCards,
cardsFaceUp: bankerCardsFaceUp,
isWinner: bankerIsWinner,
containerWidth: width
containerWidth: width,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed
)
}
.frame(width: width)
@ -199,7 +205,9 @@ struct CardsDisplayArea: View {
bankerValue: 0,
playerIsWinner: false,
bankerIsWinner: false,
isTie: false
isTie: false,
showAnimations: true,
dealingSpeed: 1.0
)
}
}
@ -222,7 +230,9 @@ struct CardsDisplayArea: View {
bankerValue: 2,
playerIsWinner: true,
bankerIsWinner: false,
isTie: false
isTie: false,
showAnimations: true,
dealingSpeed: 1.0
)
}
}
@ -247,7 +257,9 @@ struct CardsDisplayArea: View {
bankerValue: 8,
playerIsWinner: false,
bankerIsWinner: true,
isTie: false
isTie: false,
showAnimations: true,
dealingSpeed: 1.0
)
}
}

View File

@ -15,11 +15,20 @@ struct CompactHandView: View {
let isWinner: Bool
/// Container width passed from parent for sizing
let containerWidth: CGFloat
/// Whether to show dealing animations
let showAnimations: Bool
/// Speed multiplier for dealing animations
let dealingSpeed: Double
// MARK: - Environment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
/// Scaled animation duration based on dealing speed.
private var animationDuration: Double {
Design.Animation.springDuration * dealingSpeed
}
// MARK: - Constants
/// Overlap ratio relative to card width (negative = overlap)
@ -90,10 +99,25 @@ struct CompactHandView: View {
cardWidth: cardWidth
)
.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 {
@ -130,7 +154,9 @@ struct CompactHandView: View {
cards: [],
cardsFaceUp: [],
isWinner: false,
containerWidth: 160
containerWidth: 160,
showAnimations: true,
dealingSpeed: 1.0
)
}
}
@ -145,7 +171,9 @@ struct CompactHandView: View {
],
cardsFaceUp: [true, true],
isWinner: false,
containerWidth: 160
containerWidth: 160,
showAnimations: true,
dealingSpeed: 1.0
)
}
}
@ -161,7 +189,9 @@ struct CompactHandView: View {
],
cardsFaceUp: [true, true, true],
isWinner: true,
containerWidth: 160
containerWidth: 160,
showAnimations: true,
dealingSpeed: 1.0
)
}
}
@ -176,7 +206,9 @@ struct CompactHandView: View {
],
cardsFaceUp: [false, false],
isWinner: false,
containerWidth: 160
containerWidth: 160,
showAnimations: true,
dealingSpeed: 1.0
)
}
}

173
Baccarat/README.md Normal file
View 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.
![Platform](https://img.shields.io/badge/platform-iOS%2026%2B-blue)
![Swift](https://img.shields.io/badge/Swift-6.2-orange)
![SwiftUI](https://img.shields.io/badge/SwiftUI-✓-green)
## 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*

View File

@ -248,20 +248,92 @@ final class BlackjackEngine {
// 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.)
///
/// 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 {
let playerValue = playerHand.value
let dealerValue = dealerUpCard.blackjackValue
let dealerRank = dealerUpCard.rank
let isSoft = playerHand.isSoft
let canDouble = playerHand.cards.count == 2
let surrenderAvailable = settings.lateSurrender
let dealerHitsS17 = settings.dealerHitsSoft17
// SURRENDER (when available) - check first
if surrenderAvailable && playerHand.cards.count == 2 {
// 16 vs 9, 10, A - Surrender
if playerValue == 16 && !isSoft && (dealerValue >= 9 || dealerValue == 1) {
// Helper: Convert dealer rank to numeric value (Ace = 11 for comparison)
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
}
}()
// 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")
}
// 15 vs 10 - Surrender
@ -269,46 +341,11 @@ final class BlackjackEngine {
return String(localized: "Surrender")
}
// 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")
}
}
// 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)
if isSoft {
switch playerValue {
@ -396,20 +433,45 @@ final class BlackjackEngine {
/// Returns the count-adjusted strategy recommendation with deviation explanation.
/// 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 {
let basicHint = getHint(playerHand: playerHand, dealerUpCard: dealerUpCard)
let tc = Int(trueCount.rounded())
let playerValue = playerHand.value
let dealerValue = dealerUpCard.blackjackValue
let dealerRank = dealerUpCard.rank
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
let tcDisplay = tc >= 0 ? "+\(tc)" : "\(tc)"
// 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)
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 {
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)
if playerValue == 12 && !isSoft && dealerValue == 2 {
if tc >= 3 {
@ -451,41 +516,42 @@ final class BlackjackEngine {
}
// 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 {
return String(localized: "Stand, not Hit (TC \(tcDisplay))")
}
}
// 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 {
return String(localized: "Double, not Hit (TC \(tcDisplay))")
}
}
// 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 {
return String(localized: "Double, not Hit (TC \(tcDisplay))")
}
}
// 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 {
return String(localized: "Double, not Hit (TC \(tcDisplay))")
}
}
// 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 {
return String(localized: "Double, not Hit (TC \(tcDisplay))")
}
}
// 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 tc >= 5 {
return String(localized: "Split, not Stand (TC \(tcDisplay))")

View File

@ -53,6 +53,13 @@ final class GameState {
/// Whether to show side bet toast notifications.
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.
var showReshuffleNotification: Bool = false
@ -67,6 +74,15 @@ final class GameState {
/// Index of the hand currently being played.
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.
var activeHand: BlackjackHand? {
guard activeHandIndex < playerHands.count else { return nil }
@ -177,18 +193,21 @@ final class GameState {
/// Whether the current hand can hit.
var canHit: Bool {
guard !isProcessingAction else { return false }
guard case .playerTurn = currentPhase else { return false }
return activeHand?.canHit ?? false
}
/// Whether the current hand can stand.
var canStand: Bool {
guard !isProcessingAction else { return false }
guard case .playerTurn = currentPhase else { return false }
return !(activeHand?.isBusted ?? true)
}
/// Whether the current hand can double.
var canDouble: Bool {
guard !isProcessingAction else { return false }
guard case .playerTurn = currentPhase else { return false }
guard let hand = activeHand else { return false }
return engine.canDoubleDown(hand: hand, balance: balance)
@ -196,6 +215,7 @@ final class GameState {
/// Whether the current hand can split.
var canSplit: Bool {
guard !isProcessingAction else { return false }
guard case .playerTurn = currentPhase else { return false }
guard let hand = activeHand else { return false }
let splitCount = playerHands.count - 1
@ -204,14 +224,16 @@ final class GameState {
/// Whether the player can surrender.
var canSurrender: Bool {
guard !isProcessingAction else { return false }
guard case .playerTurn = currentPhase else { return false }
guard let hand = activeHand else { return false }
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 {
balance < settings.minBet && currentPhase == .betting && currentBet == 0
currentPhase == .betting && (balance + currentBet) < settings.minBet
}
/// Total rounds played.
@ -438,11 +460,18 @@ final class GameState {
// Ensure enough cards for a full hand - reshuffle if needed
if !engine.canDealNewHand {
engine.reshuffle()
showReshuffleNotification = true
// Show notification with animation
withAnimation(.spring(duration: Design.Animation.springDuration)) {
showReshuffleNotification = true
}
// Auto-dismiss after 2 seconds
Task {
try? await Task.sleep(for: .seconds(2))
showReshuffleNotification = false
withAnimation(.spring(duration: Design.Animation.springDuration)) {
showReshuffleNotification = false
}
}
}
@ -478,7 +507,11 @@ final class GameState {
evaluateSideBets()
// 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
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
/// Player hits (takes another card).
func hit() async {
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 }
playerHands[activeHandIndex].cards.append(card)
@ -584,6 +632,14 @@ final class GameState {
func stand() async {
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
await moveToNextHand()
}
@ -592,6 +648,14 @@ final class GameState {
func doubleDown() async {
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
balance -= additionalBet
playerHands[activeHandIndex].isDoubledDown = true
@ -616,6 +680,14 @@ final class GameState {
func split() async {
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 splitCard = originalHand.cards[1]
@ -660,6 +732,14 @@ final class GameState {
func surrender() async {
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
await completeRound()
}
@ -787,7 +867,7 @@ final class GameState {
// Auto-hide toasts after delay
Task {
try? await Task.sleep(for: .seconds(3))
try? await Task.sleep(for: Design.Toast.duration)
showSideBetToasts = false
}
}
@ -947,12 +1027,19 @@ final class GameState {
// Check if shoe needs reshuffling
if engine.needsReshuffle {
engine.reshuffle()
showReshuffleNotification = true
// Auto-dismiss after a delay
// Show notification after delay so it appears after result banner is dismissed
Task {
try? await Task.sleep(for: .seconds(2))
showReshuffleNotification = false
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
}
}
}
}

View File

@ -105,6 +105,9 @@ final class GameSettings {
/// Whether insurance is offered.
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)
var blackjackPayout: Double = 1.5 { didSet { save() } }
@ -237,6 +240,7 @@ final class GameSettings {
self.noHoleCard = data.noHoleCard
self.blackjackPayout = data.blackjackPayout
self.insuranceAllowed = data.insuranceAllowed
self.neverAskInsurance = data.neverAskInsurance
self.sideBetsEnabled = data.sideBetsEnabled
self.showAnimations = data.showAnimations
self.dealingSpeed = data.dealingSpeed
@ -263,6 +267,7 @@ final class GameSettings {
noHoleCard: noHoleCard,
blackjackPayout: blackjackPayout,
insuranceAllowed: insuranceAllowed,
neverAskInsurance: neverAskInsurance,
sideBetsEnabled: sideBetsEnabled,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
@ -290,6 +295,7 @@ final class GameSettings {
noHoleCard = false
blackjackPayout = 1.5
insuranceAllowed = true
neverAskInsurance = false
sideBetsEnabled = false
showAnimations = true
dealingSpeed = 1.0

File diff suppressed because it is too large Load Diff

View File

@ -66,6 +66,7 @@ struct BlackjackSettingsData: PersistableGameData {
noHoleCard: false,
blackjackPayout: 1.5,
insuranceAllowed: true,
neverAskInsurance: false,
sideBetsEnabled: false,
showAnimations: true,
dealingSpeed: 1.0,
@ -90,6 +91,7 @@ struct BlackjackSettingsData: PersistableGameData {
var noHoleCard: Bool
var blackjackPayout: Double
var insuranceAllowed: Bool
var neverAskInsurance: Bool
var sideBetsEnabled: Bool
var showAnimations: Bool
var dealingSpeed: Double

View File

@ -62,6 +62,7 @@ enum Design {
static let hintIconSize: CGFloat = 24
static let hintPaddingH: CGFloat = 10
static let hintPaddingV: CGFloat = 10
static let hintMinWidth: CGFloat = 90
// Hand icons
static let handIconSize: CGFloat = 18
@ -84,7 +85,7 @@ enum Design {
// Result banner
static let resultRowAmountWidth: CGFloat = 70
static let resultRowResultWidth: CGFloat = 150
static let resultRowResultWidth: CGFloat = 120
// Side bet zones
static let sideBetLabelFontSize: CGFloat = 13
@ -109,6 +110,27 @@ enum Design {
/// Bounce for side bet toast animations
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

View File

@ -19,8 +19,8 @@ struct GameTableView: View {
@State private var showRules = false
@State private var showStats = false
/// Full screen size (measured from TableBackgroundView - stable, doesn't change with content)
@State private var fullScreenSize: CGSize = CGSize(width: 375, height: 667)
/// Screen size for card sizing (measured from TableBackgroundView)
@State private var screenSize: CGSize = CGSize(width: 375, height: 667)
// MARK: - Environment
@ -42,46 +42,77 @@ struct GameTableView: View {
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
var body: some View {
Group {
if let state = gameState {
mainGameView(state: state)
} else {
ProgressView()
.task {
gameState = GameState(settings: settings)
}
mainGameView(state: state)
.onAppear {
if gameState == nil {
gameState = GameState(settings: settings)
}
}
}
.sheet(isPresented: $showSettings) {
.sheet(isPresented: $showSettings) {
SettingsView(settings: settings, gameState: gameState)
}
.sheet(isPresented: $showRules) {
RulesHelpView()
}
.sheet(isPresented: $showStats) {
if let state = gameState {
StatisticsSheetView(state: state)
}
StatisticsSheetView(state: state)
}
}
// Use global debug flag from Design constants
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
@ViewBuilder
private func mainGameView(state: GameState) -> some View {
ZStack {
// Background - measures full screen size (stable)
// Background - measures screen size for card sizing
TableBackgroundView()
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { size in
fullScreenSize = size
screenSize = size
}
mainContent(state: state)
@ -97,7 +128,7 @@ struct GameTableView: View {
balance: state.balance,
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.cardsRemaining)" : nil,
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
onReset: { state.resetGame() },
leadingButtons: hintToolbarButtons(for: state),
onSettings: { showSettings = true },
onHelp: { showRules = true },
onStats: { showStats = true }
@ -105,32 +136,19 @@ struct GameTableView: View {
.frame(maxWidth: maxContentWidth)
.debugBorder(showDebugBorders, color: .cyan, label: "TopBar")
// Card count display (when enabled)
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
// Table layout
BlackjackTableView(
state: state,
selectedChip: selectedChip,
fullScreenSize: fullScreenSize
fullScreenSize: screenSize
)
.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
// Full width on iPad so all chips are tappable
if state.currentPhase == .betting && !state.showResultBanner {
ChipSelectorView(
selectedChip: $selectedChip,
@ -138,14 +156,7 @@ struct GameTableView: View {
currentBet: state.minBetForChipSelector,
maxBet: state.settings.maxBet
)
.frame(maxWidth: maxContentWidth)
.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
@ -154,26 +165,34 @@ struct GameTableView: View {
.padding(.bottom, Design.Spacing.small)
.debugBorder(showDebugBorders, color: .blue, label: "ActionBtns")
}
.frame(maxWidth: .infinity, alignment: .top)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.zIndex(1)
.onChange(of: state.currentPhase) { oldPhase, newPhase in
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)
if state.currentPhase == .insurance {
Color.clear
.overlay(alignment: .center) {
InsurancePopupView(
betAmount: state.currentBet / 2,
balance: state.balance,
onTake: { Task { await state.takeInsurance() } },
onDecline: { state.declineInsurance() }
)
InsurancePopupView(
betAmount: state.currentBet / 2,
balance: state.balance,
onTake: { Task { await state.takeInsurance() } },
onDecline: { state.declineInsurance() },
onNeverAsk: { state.neverAskInsurance() }
)
}
.ignoresSafeArea()
.allowsHitTesting(true)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.zIndex(100)
}
@ -181,13 +200,13 @@ struct GameTableView: View {
if state.showResultBanner, let result = state.lastRoundResult {
Color.clear
.overlay(alignment: .center) {
ResultBannerView(
result: result,
currentBalance: state.balance,
minBet: state.settings.minBet,
onNewRound: { state.newRound() },
onPlayAgain: { state.resetGame() }
)
ResultBannerView(
result: result,
currentBalance: state.balance,
minBet: state.settings.minBet,
onNewRound: { state.newRound() },
onPlayAgain: { state.resetGame() }
)
.onAppear {
Design.debugLog("🎯 RESULT BANNER APPEARED")
}
@ -210,15 +229,16 @@ struct GameTableView: View {
if state.isGameOver && !state.showResultBanner {
Color.clear
.overlay(alignment: .center) {
GameOverView(
roundsPlayed: state.roundsPlayed,
onPlayAgain: { state.resetGame() }
)
}
GameOverView(
roundsPlayed: state.roundsPlayed,
onPlayAgain: { state.resetGame() }
)
}
.ignoresSafeArea()
.allowsHitTesting(true)
.zIndex(100)
}
}
.onChange(of: state.playerHands.count) { oldCount, newCount in
Design.debugLog("👥 Player hands count: \(oldCount)\(newCount)")

View File

@ -3,11 +3,13 @@
// Blackjack
//
// Displays the result of a round with breakdown.
// Uses the shared ResultBannerView from CasinoKit.
//
import SwiftUI
import CasinoKit
/// Result banner for Blackjack using the shared CasinoKit component.
struct ResultBannerView: View {
let result: RoundResult
let currentBalance: Int
@ -15,22 +17,9 @@ struct ResultBannerView: View {
let onNewRound: () -> 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
private var isGameOver: Bool {
currentBalance < minBet
}
/// Overall result based on total winnings (what the player actually cares about)
/// Overall result text based on total winnings
private var overallResultText: String {
if result.totalWinnings > 0 {
return String(localized: "WIN!")
@ -48,60 +37,35 @@ struct ResultBannerView: View {
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 {
ZStack {
// Full screen dark background
Color.black.opacity(Design.Opacity.strong)
// Content card
VStack(spacing: Design.Spacing.xLarge) {
// Overall result based on total winnings
Text(overallResultText)
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
.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) {
CasinoResultBannerView(
resultText: overallResultText,
resultColor: overallResultColor,
totalWinnings: result.totalWinnings,
currentBalance: currentBalance,
minBet: minBet,
breakdownContent: {
ResultBreakdownCard {
// Hand results
ForEach(result.handResults.indices, id: \.self) { index in
let handResult = result.handResults[index]
let handWinnings = index < result.handWinnings.count ? result.handWinnings[index] : nil
// Hand numbering: index 0 = Hand 1 (played first, displayed rightmost)
let handLabel = result.handResults.count > 1
? String(localized: "Hand \(index + 1)")
: String(localized: "Main Hand")
// Show amounts for split hands, or for single hand if there are winnings
let showAmount = result.hadSplit && handWinnings != nil
ResultRow(
HandResultRow(
label: handLabel,
result: handResult,
amount: showAmount ? handWinnings : nil
)
}
// Insurance result
if let insuranceResult = result.insuranceResult {
let showInsAmount = result.insuranceWinnings != 0
ResultRow(
HandResultRow(
label: String(localized: "Insurance"),
result: insuranceResult,
amount: showInsAmount ? result.insuranceWinnings : nil
@ -127,124 +91,44 @@ struct ResultBannerView: View {
)
}
}
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.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)
},
onNewRound: onNewRound,
onPlayAgain: onPlayAgain
)
}
}
// 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 result: HandResult
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? {
guard let amount = amount else { return nil }
if amount > 0 {
return "+$\(amount)"
return "+\(amount)"
} else if amount < 0 {
return "-$\(abs(amount))"
return "\(amount)"
} else {
return "$0"
return nil
}
}
@ -256,51 +140,56 @@ struct ResultRow: 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)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
.debugBorder(showDebugBorders, color: .blue, label: "Label")
.font(.system(size: fontSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.heavy))
Spacer()
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer")
// Show amount if provided
// Amount (if provided)
if let amountText = amountText {
Text(amountText)
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded))
.font(.system(size: fontSize, weight: .semibold, design: .rounded))
.foregroundStyle(amountColor)
.frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing)
.debugBorder(showDebugBorders, color: .green, label: "Amount")
}
// Result text
Text(result.displayText)
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
.font(.system(size: fontSize + 2, weight: .bold))
.foregroundStyle(result.color)
.frame(width: Design.Size.resultRowResultWidth, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
.debugBorder(showDebugBorders, color: .red, label: "Result")
}
.debugBorder(showDebugBorders, color: .white, label: "ResultRow")
}
}
// MARK: - Side Bet Result Row
struct SideBetResultRow: View {
/// Row displaying a side bet result.
private struct SideBetResultRow: View {
let label: String
let resultText: String
let isWin: Bool
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 {
if amount > 0 {
return "+$\(amount)"
return "+\(amount)"
} else if amount < 0 {
return "-$\(abs(amount))"
return "\(amount)"
} else {
return "$0"
}
@ -317,33 +206,36 @@ struct SideBetResultRow: 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)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
.debugBorder(showDebugBorders, color: .blue, label: "Label")
.font(.system(size: fontSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.heavy))
Spacer()
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer")
// Amount
Text(amountText)
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded))
.font(.system(size: fontSize, weight: .semibold, design: .rounded))
.foregroundStyle(amountColor)
.frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing)
.debugBorder(showDebugBorders, color: .green, label: "Amount")
// Result text
Text(resultText)
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
.font(.system(size: fontSize + 2, weight: .bold))
.foregroundStyle(resultColor)
.frame(width: Design.Size.resultRowResultWidth, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
.debugBorder(showDebugBorders, color: .red, label: "Result")
}
.debugBorder(showDebugBorders, color: .white, label: "SideBetRow")
}
}
// MARK: - Previews
#Preview("Single Hand") {
ResultBannerView(
result: RoundResult(
@ -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: {}
)
}

View File

@ -146,6 +146,15 @@ struct SettingsView: View {
isOn: $settings.showCardsRemaining,
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)
.padding(.vertical, Design.Spacing.xSmall)
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
if state.persistence.iCloudEnabled {
Divider()
@ -227,6 +236,7 @@ struct SettingsView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
Divider()
.background(Color.white.opacity(Design.Opacity.subtle))
@ -235,11 +245,13 @@ struct SettingsView: View {
state.persistence.sync()
} label: {
HStack {
Image(systemName: "arrow.triangle.2.circlepath")
Text(String(localized: "Sync Now"))
Spacer()
Image(systemName: "arrow.triangle.2.circlepath")
}
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(accent)
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
}
}
} else {
@ -257,7 +269,7 @@ struct SettingsView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.padding(.vertical, Design.Spacing.xSmall)
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
}
}
}
@ -287,21 +299,50 @@ struct SettingsView: View {
Text(winnings >= 0 ? "+$\(winnings)" : "-$\(abs(winnings))")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(winnings >= 0 ? .green : .red)
}
}
}
Divider()
.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) {
showClearDataAlert = true
} label: {
HStack {
Image(systemName: "trash")
Text(String(localized: "Clear All Data"))
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
Spacer()
Image(systemName: "trash")
.font(.system(size: Design.BaseFontSize.large))
}
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.red)
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
}
}
}
@ -320,6 +361,7 @@ struct SettingsView: View {
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
}
}

View File

@ -19,6 +19,7 @@ struct BlackjackTableView: View {
@Environment(\.verticalSizeClass) private var verticalSizeClass
// MARK: - Computed from stable screen size
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: .title) private var valueFontSize: CGFloat = Design.BaseFontSize.xLarge
@ScaledMetric(relativeTo: .caption) private var hintFontSize: CGFloat = Design.BaseFontSize.small
// MARK: - Dynamic Card Sizing
@ -47,8 +47,8 @@ struct BlackjackTableView: View {
/// Card width based on full screen height (stable - doesn't change with content)
private var cardWidth: CGFloat {
let maxDimension = screenHeight
let percentage: CGFloat = 0.13 // ~10% of screen
let maxDimension = screenHeight
let percentage: CGFloat = 0.18 // ~10% of screen
return maxDimension * percentage
}
@ -58,56 +58,78 @@ struct BlackjackTableView: View {
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
private var showDebugBorders: Bool { Design.showDebugBorders }
/// Dynamic spacer height based on screen size.
/// Formula: spacing = clamp((screenHeight - baseline) * scale, min, max)
/// This produces smooth scaling across all device sizes:
/// - iPhone SE (~667pt): ~20pt
/// - iPhone Pro Max (~932pt): ~76pt
/// - iPad Mini (~1024pt): ~95pt
/// - iPad Pro 12.9" (~1366pt): ~150pt (capped)
private var dealerPlayerSpacing: CGFloat {
let baseline: CGFloat = 550 // Below this, use minimum
let scale: CGFloat = 0.18 // 20% of height above baseline
let minSpacing: CGFloat = 10 // Floor for smallest screens
let maxSpacing: CGFloat = 150 // Ceiling for largest screens
// MARK: - Hint Toast Helper
/// Shows the hint toast with auto-dismiss timer.
private func showHintToastWithTimer(state: GameState) {
// Generate new ID to invalidate any pending dismiss tasks
let currentID = UUID()
state.hintDisplayID = currentID
let calculated = (screenHeight - baseline) * scale
return min(maxSpacing, max(minSpacing, calculated))
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 {
VStack(spacing: Design.Spacing.small) {
VStack(spacing: 0) {
// Dealer area
DealerHandView(
hand: state.dealerHand,
showHoleCard: state.shouldShowDealerHoleCard,
showCardCount: showCardCount,
showAnimations: state.settings.showAnimations,
dealingSpeed: state.settings.dealingSpeed,
cardWidth: cardWidth,
cardSpacing: cardSpacing
)
.debugBorder(showDebugBorders, color: .red, label: "Dealer")
// Flexible space between dealer and player - scales with screen size
Spacer(minLength: dealerPlayerSpacing)
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer \(Int(dealerPlayerSpacing))")
// Top spacer
Spacer(minLength: Design.Spacing.small)
.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
if state.playerHands.first?.cards.isEmpty == false {
ZStack {
PlayerHandsView(
hands: state.playerHands,
activeHandIndex: state.activeHandIndex,
isPlayerTurn: state.isPlayerTurn,
showCardCount: showCardCount,
cardWidth: cardWidth,
cardSpacing: cardSpacing
)
PlayerHandsView(
hands: state.playerHands,
activeHandIndex: state.activeHandIndex,
isPlayerTurn: state.isPlayerTurn,
showCardCount: showCardCount,
showAnimations: state.settings.showAnimations,
dealingSpeed: state.settings.dealingSpeed,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
currentHint: state.currentHint,
showHintToast: state.showHintToast
)
// Side bet toasts (positioned on left/right sides to not cover cards)
if state.settings.sideBetsEnabled && state.showSideBetToasts {
@ -139,6 +161,41 @@ struct BlackjackTableView: View {
.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)
.transition(.opacity)
.debugBorder(showDebugBorders, color: .green, label: "Player")
@ -160,22 +217,12 @@ struct BlackjackTableView: View {
if let hint = state.bettingHint {
BettingHintView(hint: hint, trueCount: state.engine.trueCount)
.transition(.opacity)
.padding(.vertical, 10)
.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(.vertical, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .white, label: "TableView")
.animation(.spring(duration: Design.Animation.springDuration), value: state.currentPhase)
}

View File

@ -12,32 +12,44 @@ struct DealerHandView: View {
let hand: BlackjackHand
let showHoleCard: Bool
let showCardCount: Bool
let showAnimations: Bool
let dealingSpeed: Double
let cardWidth: CGFloat
let cardSpacing: CGFloat
@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 {
VStack(spacing: Design.Spacing.small) {
// Label and value
// Label and value - fixed height prevents vertical layout shift
HStack(spacing: Design.Spacing.small) {
Text(String(localized: "DEALER"))
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.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 showHoleCard {
// All cards visible - show total hand value
ValueBadge(value: hand.value, color: Color.Hand.dealer)
.transition(.scale.combined(with: .opacity))
} else {
// Hole card hidden - show only the first (face-up) card's value
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) {
if hand.cards.isEmpty {
CardPlaceholderView(width: cardWidth)
@ -56,6 +68,16 @@ struct DealerHandView: View {
}
}
.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)
@ -65,18 +87,27 @@ struct DealerHandView: View {
}
}
}
// Result badge
if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil {
Text(result)
.font(.system(size: labelFontSize, weight: .black))
.foregroundStyle(handResultColor)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.xSmall)
.background(
Capsule()
.fill(handResultColor.opacity(Design.Opacity.hint))
)
.animation(
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 {
Text(result)
.font(.system(size: labelFontSize, weight: .black))
.foregroundStyle(.white)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.xSmall)
.background(
Capsule()
.fill(handResultColor)
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
)
.transition(.scale.combined(with: .opacity))
}
}
}
.accessibilityElement(children: .ignore)
@ -118,6 +149,8 @@ struct DealerHandView: View {
hand: BlackjackHand(),
showHoleCard: false,
showCardCount: false,
showAnimations: true,
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20
)
@ -134,6 +167,8 @@ struct DealerHandView: View {
]),
showHoleCard: false,
showCardCount: false,
showAnimations: true,
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20
)
@ -150,6 +185,8 @@ struct DealerHandView: View {
]),
showHoleCard: true,
showCardCount: true,
showAnimations: true,
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20
)

View File

@ -24,18 +24,24 @@ struct HintView: View {
Image(systemName: "lightbulb.fill")
.font(.system(size: iconSize))
.foregroundStyle(.yellow)
Text(String(localized: "Hint: \(hint)"))
Text(hint)
.font(.system(size: fontSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
}
.padding(.horizontal, paddingH)
.padding(.vertical, paddingV)
.frame(minWidth: Design.Size.hintMinWidth, alignment: .leading)
.background(
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)
.accessibilityLabel(String(localized: "Hint"))
.accessibilityValue(hint)

View File

@ -13,6 +13,9 @@ struct InsurancePopupView: View {
let balance: Int
let onTake: () -> Void
let onDecline: () -> Void
let onNeverAsk: () -> Void
@State private var showContent = false
var body: some View {
ZStack {
@ -45,39 +48,50 @@ struct InsurancePopupView: View {
.padding(.bottom, Design.Spacing.small)
// Buttons
HStack(spacing: Design.Spacing.large) {
// Decline button
Button(action: onDecline) {
Text(String(localized: "No Thanks"))
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.medium)
.background(
Capsule()
.fill(Color.red.opacity(Design.Opacity.heavy))
)
}
// Accept button (only if can afford)
if balance >= betAmount {
Button(action: onTake) {
Text(String(localized: "Yes ($\(betAmount))"))
.font(.system(size: Design.BaseFontSize.medium, weight: .bold))
.foregroundStyle(.black)
VStack(spacing: Design.Spacing.medium) {
HStack(spacing: Design.Spacing.large) {
// Decline button
Button(action: onDecline) {
Text(String(localized: "No Thanks"))
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.medium)
.background(
Capsule()
.fill(
LinearGradient(
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
startPoint: .top,
endPoint: .bottom
)
)
.fill(Color.red.opacity(Design.Opacity.heavy))
)
}
// Accept button (only if can afford)
if balance >= betAmount {
Button(action: onTake) {
Text(String(localized: "Yes ($\(betAmount))"))
.font(.system(size: Design.BaseFontSize.medium, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.medium)
.background(
Capsule()
.fill(
LinearGradient(
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
startPoint: .top,
endPoint: .bottom
)
)
)
}
}
}
// 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)
}
}
}
@ -91,6 +105,13 @@ struct InsurancePopupView: View {
RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge)
.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)
.accessibilityAddTraits(.isModal)
@ -104,7 +125,8 @@ struct InsurancePopupView: View {
betAmount: 500,
balance: 4500,
onTake: {},
onDecline: {}
onDecline: {},
onNeverAsk: {}
)
}
@ -113,7 +135,8 @@ struct InsurancePopupView: View {
betAmount: 500,
balance: 200,
onTake: {},
onDecline: {}
onDecline: {},
onNeverAsk: {}
)
}

View File

@ -16,9 +16,17 @@ struct PlayerHandsView: View {
let activeHandIndex: Int
let isPlayerTurn: Bool
let showCardCount: Bool
let showAnimations: Bool
let dealingSpeed: Double
let cardWidth: 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
private var totalCardCount: Int {
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)
// Play order: Hand 1 played first (rightmost), then Hand 2, etc.
ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in
let isActiveHand = index == activeHandIndex && isPlayerTurn
PlayerHandView(
hand: hand,
isActive: index == activeHandIndex && isPlayerTurn,
isActive: isActiveHand,
showCardCount: showCardCount,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
// Hand numbers: rightmost (index 0) is Hand 1, played first
handNumber: hands.count > 1 ? index + 1 : nil,
cardWidth: cardWidth,
cardSpacing: cardSpacing
cardSpacing: cardSpacing,
// Only show hint on the active hand
currentHint: isActiveHand ? currentHint : nil,
showHintToast: isActiveHand && showHintToast
)
.id(hand.id)
.transition(.scale.combined(with: .opacity))
@ -85,10 +99,23 @@ struct PlayerHandView: View {
let hand: BlackjackHand
let isActive: Bool
let showCardCount: Bool
let showAnimations: Bool
let dealingSpeed: Double
let handNumber: Int?
let cardWidth: 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: .caption) private var handNumberSize: CGFloat = Design.Size.handNumberFontSize
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.Size.handIconSize
@ -115,9 +142,25 @@ struct PlayerHandView: View {
}
}
.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(.vertical, Design.Spacing.medium)
.background(
@ -132,7 +175,7 @@ struct PlayerHandView: View {
)
)
.overlay {
// Result badge - centered on cards
// Result badge - centered on cards (takes priority over hint)
if let result = hand.result {
Text(result.displayText)
.font(.system(size: labelFontSize, weight: .black))
@ -145,6 +188,10 @@ struct PlayerHandView: View {
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
)
.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())
@ -217,8 +264,12 @@ struct PlayerHandView: View {
activeHandIndex: 0,
isPlayerTurn: true,
showCardCount: false,
showAnimations: true,
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20
cardSpacing: -20,
currentHint: nil,
showHintToast: false
)
}
}
@ -234,8 +285,12 @@ struct PlayerHandView: View {
activeHandIndex: 0,
isPlayerTurn: true,
showCardCount: false,
showAnimations: true,
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20
cardSpacing: -20,
currentHint: "Hit",
showHintToast: true
)
}
}
@ -265,8 +320,12 @@ struct PlayerHandView: View {
activeHandIndex: 1,
isPlayerTurn: true,
showCardCount: true,
showAnimations: true,
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20
cardSpacing: -20,
currentHint: "Stand",
showHintToast: true
)
}
}

141
Blackjack/README.md Normal file
View 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.
![Platform](https://img.shields.io/badge/platform-iOS%2026%2B-blue)
![Swift](https://img.shields.io/badge/Swift-6.2-orange)
![SwiftUI](https://img.shields.io/badge/SwiftUI-✓-green)
## 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*

View File

@ -126,7 +126,26 @@ TopBarView(
balance: 10_500,
secondaryInfo: "411",
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 },
onHelp: { showRules = true },
onStats: { showStats = true }

View File

@ -27,6 +27,9 @@
// MARK: - Overlays
// - GameOverView
// - CasinoResultBannerView (animated result banner with staggered animations)
// - ResultBreakdownCard (styled container for bet breakdown)
// - ResultItemRow (generic result row with icon, label, amount)
// MARK: - Table
// - TableBackgroundView, FeltPatternView

View File

@ -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.",
"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" : {
"localizations" : {
"en" : {
@ -1334,6 +1342,10 @@
}
}
},
"New Round" : {
"comment" : "A button label that initiates a new round of a casino game.",
"isCommentAutoGenerated" : true
},
"Nine" : {
"localizations" : {
"en" : {
@ -1558,6 +1570,7 @@
}
},
"Reset Game" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"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" : {
"localizations" : {
"en" : {

View File

@ -37,7 +37,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
public var iCloudAvailable: Bool {
let token = FileManager.default.ubiquityIdentityToken
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
}
@ -120,30 +120,30 @@ public final class CloudSyncManager<T: PersistableGameData> {
/// Checks for iCloud data after a brief delay (for fresh installs).
private func scheduleDelayedCloudCheck() {
print("CloudSyncManager[\(T.gameIdentifier)]: Scheduling delayed cloud check...")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Scheduling delayed cloud check...")
Task { @MainActor in
// Wait for iCloud to sync (typically takes 1-2 seconds on fresh install)
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)
var syncResult = false
await MainActor.run {
syncResult = iCloudStore.synchronize()
}
print("CloudSyncManager[\(T.gameIdentifier)]: synchronize() returned \(syncResult)")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: synchronize() returned \(syncResult)")
// Check what's in the store
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
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 {
print("CloudSyncManager[\(T.gameIdentifier)]: Cloud has more data, updating...")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Cloud has more data, updating...")
data = cloudData
hasCompletedInitialSync = true
onCloudDataReceived?(cloudData)
@ -160,11 +160,11 @@ public final class CloudSyncManager<T: PersistableGameData> {
userInfo: ["gameIdentifier": T.gameIdentifier]
)
} 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 {
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
guard let encoded = try? encoder.encode(dataToSave) else {
print("CloudSyncManager: Failed to encode game data")
CasinoDesign.debugLog("CloudSyncManager: Failed to encode game data")
return
}
@ -194,7 +194,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
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.
@ -216,31 +216,31 @@ public final class CloudSyncManager<T: PersistableGameData> {
switch (localData, cloudData) {
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
case (let local?, nil):
print("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
finalData = local
case (nil, let cloud?):
print("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud data")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud data")
finalData = cloud
case (let local?, let cloud?):
// Use whichever has more rounds played
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
// Update local with cloud data
if let encoded = try? encoder.encode(cloud) {
UserDefaults.standard.set(encoded, forKey: localKey)
}
} 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
} else {
print("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
finalData = local
}
}
@ -305,7 +305,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
switch reason {
case NSUbiquitousKeyValueStoreServerChange,
NSUbiquitousKeyValueStoreInitialSyncChange:
print("CloudSyncManager[\(T.gameIdentifier)]: Data changed from another device")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Data changed from another device")
syncStatus = "Received update"
// Reload and notify
@ -327,11 +327,11 @@ public final class CloudSyncManager<T: PersistableGameData> {
}
case NSUbiquitousKeyValueStoreQuotaViolationChange:
print("CloudSyncManager[\(T.gameIdentifier)]: iCloud quota exceeded")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud quota exceeded")
syncStatus = "Storage full"
case NSUbiquitousKeyValueStoreAccountChange:
print("CloudSyncManager[\(T.gameIdentifier)]: iCloud account changed")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud account changed")
syncStatus = "Account changed"
// Reload with new account
data = load()
@ -355,7 +355,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
data = T.empty
syncStatus = "Data cleared"
print("CloudSyncManager[\(T.gameIdentifier)]: All data cleared")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: All data cleared")
}
}

View File

@ -10,6 +10,20 @@ import SwiftUI
/// Shared design constants for casino game components.
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
public enum Spacing {
@ -147,6 +161,9 @@ public enum CasinoDesign {
/// Checkmark size.
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.
public static let actionButtonHeight: CGFloat = 50
public static var actionButtonMinWidth: CGFloat {

View File

@ -7,6 +7,31 @@
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.
public struct TopBarView: View {
/// The current balance to display.
@ -18,11 +43,8 @@ public struct TopBarView: View {
/// Icon for secondary info.
public let secondaryIcon: String?
/// Whether to show the reset button.
public let showReset: Bool
/// Action when reset is tapped.
public let onReset: (() -> Void)?
/// Custom buttons to display at the front of the toolbar (before stats/help/settings).
public let leadingButtons: [TopBarButton]
/// Action when settings is tapped.
public let onSettings: (() -> Void)?
@ -45,8 +67,7 @@ public struct TopBarView: View {
/// - balance: The current balance.
/// - secondaryInfo: Optional secondary info text.
/// - secondaryIcon: Optional SF Symbol for secondary info.
/// - showReset: Whether to show reset button.
/// - onReset: Reset button action.
/// - leadingButtons: Custom buttons to add at the front of the toolbar.
/// - onSettings: Settings button action.
/// - onHelp: Help button action.
/// - onStats: Stats button action.
@ -54,8 +75,7 @@ public struct TopBarView: View {
balance: Int,
secondaryInfo: String? = nil,
secondaryIcon: String? = nil,
showReset: Bool = true,
onReset: (() -> Void)? = nil,
leadingButtons: [TopBarButton] = [],
onSettings: (() -> Void)? = nil,
onHelp: (() -> Void)? = nil,
onStats: (() -> Void)? = nil
@ -63,8 +83,7 @@ public struct TopBarView: View {
self.balance = balance
self.secondaryInfo = secondaryInfo
self.secondaryIcon = secondaryIcon
self.showReset = showReset
self.onReset = onReset
self.leadingButtons = leadingButtons
self.onSettings = onSettings
self.onHelp = onHelp
self.onStats = onStats
@ -105,6 +124,12 @@ public struct TopBarView: View {
// Toolbar buttons
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 {
ToolbarButton(icon: "chart.bar.fill", action: onStats)
.accessibilityLabel(String(localized: "Statistics", bundle: .module))
@ -119,11 +144,6 @@ public struct TopBarView: View {
ToolbarButton(icon: "gearshape.fill", action: onSettings)
.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)
@ -156,7 +176,9 @@ private struct ToolbarButton: View {
balance: 10_500,
secondaryInfo: "411",
secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill",
onReset: {},
leadingButtons: [
TopBarButton(icon: "arrow.counterclockwise", accessibilityLabel: "Reset") {}
],
onSettings: {},
onHelp: {},
onStats: {}

View File

@ -73,12 +73,12 @@ public struct ChipSelectorView: View {
.disabled(!canUseChip(denomination))
}
}
.padding(.horizontal, CasinoDesign.Spacing.xLarge)
.padding(.vertical, CasinoDesign.Spacing.medium)
.frame(minWidth: geometry.size.width) // Center when content fits
}
.scrollClipDisabled() // Prevent harsh clipping during scroll/animation
.scrollBounceBehavior(fitsOnScreen ? .basedOnSize : .automatic)
.padding(.horizontal, CasinoDesign.Spacing.xLarge)
.padding(.vertical, CasinoDesign.Spacing.medium)
.frame(minWidth: geometry.size.width) // Center when content fits
}
.scrollClipDisabled() // Allow visual overflow during scroll/animation
.scrollBounceBehavior(fitsOnScreen ? .basedOnSize : .automatic)
}
.frame(height: CasinoDesign.Size.chipLarge + CasinoDesign.Spacing.medium * 2)
.accessibilityElement(children: .contain)

View File

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

View File

@ -75,7 +75,12 @@ struct GameTableView: View {
balance: state.balance,
secondaryInfo: "\(state.engine.shoe.cardsRemaining)",
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 },
onHelp: { showRules = true },
onStats: { showStats = true }