Compare commits
20 Commits
09770ec625
...
547f690e3c
| Author | SHA1 | Date | |
|---|---|---|---|
| 547f690e3c | |||
| 545271e9bd | |||
| 7800d3474d | |||
| 9ac5320782 | |||
| 645602b2de | |||
| d56b0b71d7 | |||
| 2c5b264f9b | |||
| a470d8984c | |||
| 45ad602d9a | |||
| 98d72d0db8 | |||
| 889e91a8ca | |||
| 582ed3237d | |||
| c358d3b2ae | |||
| 4d79f08089 | |||
| 2b0f36a12c | |||
| 71b27b671b | |||
| 21e5d901c7 | |||
| 2cd2946a80 | |||
| 1dc64ebf69 | |||
| 7234cd718a |
@ -2606,6 +2606,7 @@
|
||||
},
|
||||
"Play Again" : {
|
||||
"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" : {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
173
Baccarat/README.md
Normal file
@ -0,0 +1,173 @@
|
||||
# Baccarat - Casino Card Game
|
||||
|
||||
A feature-rich Baccarat (Punto Banco) app for iOS built with SwiftUI. Experience authentic casino gameplay with side bets, road map history, and detailed statistics — all without risking real money.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### 🎰 Authentic Punto Banco Gameplay
|
||||
- Complete Baccarat rules with automatic third card logic
|
||||
- Natural detection (8 or 9 on initial deal)
|
||||
- Multi-deck shoe support (1, 6, or 8 decks)
|
||||
- Animated card dealing with sound effects and haptics
|
||||
- Automatic shoe reshuffling with burn card
|
||||
|
||||
### 💰 Betting Options
|
||||
|
||||
#### Main Bets
|
||||
| Bet Type | Payout | House Edge |
|
||||
|----------|--------|------------|
|
||||
| Player | 1:1 | 1.24% |
|
||||
| Banker | 0.95:1 (5% commission) | 1.06% |
|
||||
| Tie | 8:1 | 14.4% |
|
||||
|
||||
#### Side Bets
|
||||
| Bet Type | Payout |
|
||||
|----------|--------|
|
||||
| Player Pair | 11:1 |
|
||||
| Banker Pair | 11:1 |
|
||||
| Dragon Bonus (Natural) | 1:1 |
|
||||
| Dragon Bonus (Win by 4) | 1:1 |
|
||||
| Dragon Bonus (Win by 5) | 2:1 |
|
||||
| Dragon Bonus (Win by 6) | 4:1 |
|
||||
| Dragon Bonus (Win by 7) | 6:1 |
|
||||
| Dragon Bonus (Win by 8) | 10:1 |
|
||||
| Dragon Bonus (Win by 9) | 30:1 |
|
||||
|
||||
### 📊 Road Map History
|
||||
- Visual "Big Road" style result display
|
||||
- Color-coded outcomes (Blue=Player, Red=Banker, Green=Tie)
|
||||
- Pair markers (yellow dot)
|
||||
- Natural markers (star)
|
||||
- Scrollable session history
|
||||
|
||||
### 🎚️ Table Limits
|
||||
| Level | Min Bet | Max Bet |
|
||||
|-------|---------|---------|
|
||||
| Casual | $5 | $500 |
|
||||
| Low Stakes | $10 | $1,000 |
|
||||
| Medium Stakes | $25 | $5,000 |
|
||||
| High Stakes | $100 | $10,000 |
|
||||
| VIP | $500 | $50,000 |
|
||||
|
||||
### ☁️ iCloud Sync
|
||||
- Balance and statistics sync across devices
|
||||
- Settings persist via iCloud
|
||||
- Automatic conflict resolution
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Baccarat/
|
||||
├── BaccaratApp.swift # App entry point
|
||||
├── ContentView.swift # Root view
|
||||
├── Engine/
|
||||
│ ├── BaccaratEngine.swift # Core game logic, third card rules, payouts
|
||||
│ └── GameState.swift # Observable state machine
|
||||
├── Models/
|
||||
│ ├── BetType.swift # Bet types, payouts, Dragon Bonus table
|
||||
│ ├── GameResult.swift # Round outcomes
|
||||
│ ├── GameSettings.swift # User preferences, table limits
|
||||
│ ├── Hand.swift # Baccarat hand model
|
||||
│ └── Shoe.swift # Multi-deck shoe with shuffle/burn
|
||||
├── Storage/
|
||||
│ └── BaccaratGameData.swift # Persistence models
|
||||
├── Theme/
|
||||
│ └── DesignConstants.swift # Design system tokens
|
||||
├── Views/
|
||||
│ ├── Development/ # Dev-only views (branding, icons)
|
||||
│ ├── Game/ # Main game UI components
|
||||
│ ├── Sheets/ # Modal views (settings, stats, rules)
|
||||
│ └── Table/ # Table layout, betting zones, road map
|
||||
└── Resources/
|
||||
└── Localizable.xcstrings # Localization strings
|
||||
```
|
||||
|
||||
## Game Rules
|
||||
|
||||
### Card Values
|
||||
- **Ace**: 1 point
|
||||
- **2-9**: Face value
|
||||
- **10, J, Q, K**: 0 points
|
||||
- Hand value = sum mod 10 (e.g., 7+8=15 → 5)
|
||||
|
||||
### Third Card Rules
|
||||
|
||||
#### Player
|
||||
- 0-5: Draws third card
|
||||
- 6-7: Stands
|
||||
- 8-9: Natural (no third card)
|
||||
|
||||
#### Banker (when Player draws)
|
||||
| Banker Total | Draws if Player's 3rd is... |
|
||||
|--------------|----------------------------|
|
||||
| 0-2 | Always draws |
|
||||
| 3 | 0-7, 9 (not 8) |
|
||||
| 4 | 2-7 |
|
||||
| 5 | 4-7 |
|
||||
| 6 | 6-7 |
|
||||
| 7 | Never draws |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **iOS 26.0+** target
|
||||
- **Swift 6.2** with strict concurrency
|
||||
- **SwiftUI** with `@Observable` for state management
|
||||
- **CasinoKit** — Shared package for cards, chips, sounds, and common UI
|
||||
- **CloudKit** — iCloud sync for game data and settings
|
||||
|
||||
## Requirements
|
||||
|
||||
- Xcode 16.0+
|
||||
- iOS 26.0+ deployment target
|
||||
- Swift 6.2+
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Open `CasinoGames.xcworkspace` in Xcode
|
||||
2. Select the **Baccarat** scheme
|
||||
3. Build and run on a simulator or device
|
||||
|
||||
## Localization
|
||||
|
||||
The app supports:
|
||||
- English (en)
|
||||
- Spanish - Mexico (es-MX)
|
||||
- French - Canada (fr-CA)
|
||||
|
||||
Strings are managed via String Catalogs (`Localizable.xcstrings`).
|
||||
|
||||
## Design System
|
||||
|
||||
All UI values are centralized in `DesignConstants.swift`:
|
||||
- Spacing, corner radii, font sizes
|
||||
- Opacity and shadow values
|
||||
- Animation durations
|
||||
- Semantic color definitions
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Full VoiceOver support with meaningful labels
|
||||
- Dynamic Type for scalable text
|
||||
- High-contrast visuals
|
||||
- Accessibility summaries for road map history
|
||||
|
||||
## Why Baccarat?
|
||||
|
||||
- **Lowest house edge** in the casino (1.06% on Banker bet)
|
||||
- **No skill required** — pure chance with simple rules
|
||||
- **Fast-paced** gameplay
|
||||
- **Elegant** and sophisticated atmosphere
|
||||
- **James Bond's favorite** casino game
|
||||
|
||||
## License
|
||||
|
||||
This project is proprietary software. All rights reserved.
|
||||
|
||||
---
|
||||
|
||||
*Built with ❤️ using SwiftUI*
|
||||
|
||||
@ -248,20 +248,92 @@ final class BlackjackEngine {
|
||||
|
||||
// MARK: - Basic Strategy Hint
|
||||
|
||||
/// 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))")
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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: {}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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: {}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
141
Blackjack/README.md
Normal file
@ -0,0 +1,141 @@
|
||||
# Blackjack 21 - Casino Card Game
|
||||
|
||||
A feature-rich Blackjack app for iOS built with SwiftUI. Master basic strategy, learn card counting, and enjoy an authentic casino experience — all without risking real money.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### 🎰 Authentic Casino Gameplay
|
||||
- Full Blackjack gameplay with all standard actions: Hit, Stand, Double Down, Split, Surrender
|
||||
- Insurance betting when dealer shows an Ace
|
||||
- Realistic multi-deck shoe with configurable penetration
|
||||
- Animated card dealing with sound effects and haptic feedback
|
||||
- Reshuffle notifications when the cut card is reached
|
||||
|
||||
### 🃏 Multiple Rule Variations
|
||||
| Style | Description |
|
||||
|-------|-------------|
|
||||
| **Vegas Strip** | 6 decks, dealer stands on soft 17, double after split, 3:2 blackjack |
|
||||
| **Atlantic City** | 8 decks, late surrender, re-split aces allowed |
|
||||
| **European** | No hole card (dealer gets second card after player acts) |
|
||||
| **Custom** | Configure every rule to your preference |
|
||||
|
||||
### 💰 Side Bets
|
||||
Optional side bets for extra excitement:
|
||||
|
||||
**Perfect Pairs** — Bet on your first two cards forming a pair:
|
||||
- Mixed Pair (different colors): 6:1
|
||||
- Colored Pair (same color): 12:1
|
||||
- Perfect Pair (same suit): 25:1
|
||||
|
||||
**21+3** — Poker hand from your two cards + dealer's upcard:
|
||||
- Flush: 5:1
|
||||
- Straight: 10:1
|
||||
- Three of a Kind: 30:1
|
||||
- Straight Flush: 40:1
|
||||
- Suited Trips: 100:1
|
||||
|
||||
### 📚 Basic Strategy Hints
|
||||
- Real-time strategy suggestions for every hand
|
||||
- Accounts for game rules (surrender availability, dealer hits soft 17, etc.)
|
||||
- Learn the mathematically optimal play for every situation
|
||||
|
||||
### 🎓 Card Counting Trainer
|
||||
Professional-grade tools for learning the Hi-Lo system:
|
||||
- **Running Count** — Live count as cards are dealt
|
||||
- **True Count** — Adjusted for remaining decks
|
||||
- **Betting Hints** — Recommendations based on count advantage
|
||||
- **Illustrious 18** — Count-adjusted strategy deviations
|
||||
|
||||
### 📊 Statistics Tracking
|
||||
- Win rate, blackjack count, bust rate
|
||||
- Session net profit/loss
|
||||
- Biggest wins and losses
|
||||
- Complete round history
|
||||
|
||||
### ☁️ iCloud Sync
|
||||
- Balance and statistics sync across devices
|
||||
- Settings persist via CloudKit
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Blackjack/
|
||||
├── BaccaratApp.swift # App entry point
|
||||
├── ContentView.swift # Root view
|
||||
├── Engine/
|
||||
│ ├── BlackjackEngine.swift # Core game logic, basic strategy, card counting
|
||||
│ └── GameState.swift # Observable state machine
|
||||
├── Models/
|
||||
│ ├── BetType.swift # Bet type definitions
|
||||
│ ├── GameResult.swift # Hand results and round outcomes
|
||||
│ ├── GameSettings.swift # User preferences and rule configuration
|
||||
│ ├── Hand.swift # BlackjackHand model
|
||||
│ └── SideBet.swift # Side bet types and evaluation
|
||||
├── Storage/
|
||||
│ └── BlackjackGameData.swift # Persistence models
|
||||
├── Theme/
|
||||
│ └── DesignConstants.swift # Design system tokens
|
||||
├── Views/
|
||||
│ ├── Development/ # Dev-only views (branding, icons)
|
||||
│ ├── Game/ # Main game UI components
|
||||
│ ├── Sheets/ # Modal views (settings, stats, rules)
|
||||
│ └── Table/ # Table layout components
|
||||
└── Resources/
|
||||
└── Localizable.xcstrings # Localization strings
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **iOS 26.0+** target
|
||||
- **Swift 6.2** with strict concurrency
|
||||
- **SwiftUI** with `@Observable` for state management
|
||||
- **CasinoKit** — Shared package for cards, chips, sounds, and common UI
|
||||
- **CloudKit** — iCloud sync for game data and settings
|
||||
|
||||
## Requirements
|
||||
|
||||
- Xcode 16.0+
|
||||
- iOS 26.0+ deployment target
|
||||
- Swift 6.2+
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Open `CasinoGames.xcworkspace` in Xcode
|
||||
2. Select the **Blackjack** scheme
|
||||
3. Build and run on a simulator or device
|
||||
|
||||
## Localization
|
||||
|
||||
The app supports:
|
||||
- English (en)
|
||||
- Spanish - Mexico (es-MX)
|
||||
- French - Canada (fr-CA)
|
||||
|
||||
Strings are managed via String Catalogs (`Localizable.xcstrings`).
|
||||
|
||||
## Design System
|
||||
|
||||
All UI values are centralized in `DesignConstants.swift`:
|
||||
- Spacing, corner radii, font sizes
|
||||
- Opacity and shadow values
|
||||
- Animation durations
|
||||
- Semantic color definitions
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Full VoiceOver support with meaningful labels and hints
|
||||
- Dynamic Type for scalable text
|
||||
- Accessibility announcements for game events
|
||||
|
||||
## License
|
||||
|
||||
This project is proprietary software. All rights reserved.
|
||||
|
||||
---
|
||||
|
||||
*Built with ❤️ using SwiftUI*
|
||||
|
||||
@ -126,7 +126,26 @@ TopBarView(
|
||||
balance: 10_500,
|
||||
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 }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: {}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -0,0 +1,514 @@
|
||||
//
|
||||
// ResultBannerView.swift
|
||||
// CasinoKit
|
||||
//
|
||||
// A reusable animated result banner for casino games.
|
||||
// Each game provides its own breakdown content via a @ViewBuilder closure.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A reusable result banner with staggered animations.
|
||||
///
|
||||
/// This component provides the shared structure for result displays:
|
||||
/// - Animated dark overlay
|
||||
/// - Result text with gradient and glow
|
||||
/// - Game-specific breakdown content (provided via @ViewBuilder)
|
||||
/// - Total winnings display
|
||||
/// - New Round / Play Again button
|
||||
/// - Game over handling
|
||||
///
|
||||
/// Usage:
|
||||
/// ```swift
|
||||
/// CasinoResultBannerView(
|
||||
/// resultText: "WIN!",
|
||||
/// resultColor: .green,
|
||||
/// totalWinnings: 500,
|
||||
/// currentBalance: 10500,
|
||||
/// minBet: 10,
|
||||
/// breakdownContent: {
|
||||
/// // Your game-specific breakdown views here
|
||||
/// ForEach(betResults) { bet in
|
||||
/// BetResultRow(bet: bet)
|
||||
/// }
|
||||
/// },
|
||||
/// onNewRound: { startNewRound() },
|
||||
/// onPlayAgain: { resetGame() }
|
||||
/// )
|
||||
/// ```
|
||||
public struct CasinoResultBannerView<Content: View>: View {
|
||||
|
||||
// MARK: - Parameters
|
||||
|
||||
/// The result text to display (e.g., "WIN!", "PLAYER WINS", "BLACKJACK!")
|
||||
public let resultText: String
|
||||
|
||||
/// The color associated with the result (green for win, red for loss, etc.)
|
||||
public let resultColor: Color
|
||||
|
||||
/// Total winnings/losses for this round
|
||||
public let totalWinnings: Int
|
||||
|
||||
/// Current balance to determine if game over
|
||||
public let currentBalance: Int
|
||||
|
||||
/// Minimum bet to determine if player can continue
|
||||
public let minBet: Int
|
||||
|
||||
/// Game-specific breakdown content (bet results, hand results, etc.)
|
||||
@ViewBuilder public let breakdownContent: Content
|
||||
|
||||
/// Action when "New Round" is tapped
|
||||
public let onNewRound: () -> Void
|
||||
|
||||
/// Action when "Play Again" is tapped (game over state)
|
||||
public let onPlayAgain: () -> Void
|
||||
|
||||
// MARK: - Animation States
|
||||
|
||||
@State private var showBanner = false
|
||||
@State private var showText = false
|
||||
@State private var showBreakdown = false
|
||||
@State private var showTotal = false
|
||||
@State private var showButton = false
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
// MARK: - Scaled Font Sizes (Dynamic Type)
|
||||
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var resultFontSize: CGFloat = CasinoDesign.BaseFontSize.largeTitle
|
||||
@ScaledMetric(relativeTo: .title3) private var totalFontSize: CGFloat = CasinoDesign.BaseFontSize.xxLarge + CasinoDesign.Spacing.xSmall
|
||||
@ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = CasinoDesign.BaseFontSize.xLarge
|
||||
@ScaledMetric(relativeTo: .title) private var gameOverFontSize: CGFloat = CasinoDesign.BaseFontSize.xxLarge
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Whether the player is out of money and can't continue.
|
||||
private var isGameOver: Bool {
|
||||
currentBalance < minBet
|
||||
}
|
||||
|
||||
/// Maximum width for the banner card on iPad
|
||||
private var maxBannerWidth: CGFloat {
|
||||
horizontalSizeClass == .regular ? CasinoDesign.Size.maxModalWidth : .infinity
|
||||
}
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
/// Creates a result banner view.
|
||||
/// - Parameters:
|
||||
/// - resultText: The main result text to display.
|
||||
/// - resultColor: The color for the result (affects text gradient, border, shadow).
|
||||
/// - totalWinnings: The total amount won or lost this round.
|
||||
/// - currentBalance: The player's current balance (for game over detection).
|
||||
/// - minBet: The minimum bet amount (for game over detection).
|
||||
/// - breakdownContent: Game-specific content showing bet/hand breakdown.
|
||||
/// - onNewRound: Action when "New Round" button is tapped.
|
||||
/// - onPlayAgain: Action when "Play Again" button is tapped (game over state).
|
||||
public init(
|
||||
resultText: String,
|
||||
resultColor: Color,
|
||||
totalWinnings: Int,
|
||||
currentBalance: Int,
|
||||
minBet: Int,
|
||||
@ViewBuilder breakdownContent: () -> Content,
|
||||
onNewRound: @escaping () -> Void,
|
||||
onPlayAgain: @escaping () -> Void
|
||||
) {
|
||||
self.resultText = resultText
|
||||
self.resultColor = resultColor
|
||||
self.totalWinnings = totalWinnings
|
||||
self.currentBalance = currentBalance
|
||||
self.minBet = minBet
|
||||
self.breakdownContent = breakdownContent()
|
||||
self.onNewRound = onNewRound
|
||||
self.onPlayAgain = onPlayAgain
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
// Background overlay
|
||||
Color.black.opacity(showBanner ? CasinoDesign.Opacity.medium : 0)
|
||||
.ignoresSafeArea()
|
||||
.animation(.easeIn(duration: CasinoDesign.Animation.fadeInDuration), value: showBanner)
|
||||
|
||||
// Banner card
|
||||
VStack(spacing: CasinoDesign.Spacing.medium) {
|
||||
// Game Over header (shown prominently at top when out of chips)
|
||||
if isGameOver {
|
||||
VStack(spacing: CasinoDesign.Spacing.xSmall) {
|
||||
Text(String(localized: "GAME OVER", bundle: .module))
|
||||
.font(.system(size: gameOverFontSize, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.red)
|
||||
|
||||
Text(String(localized: "You've run out of chips!", bundle: .module))
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.medium, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
|
||||
}
|
||||
.scaleEffect(showText ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
|
||||
.opacity(showText ? CasinoDesign.Scale.normal : 0)
|
||||
}
|
||||
|
||||
// Result text with gradient and glow (smaller when game over)
|
||||
Text(resultText)
|
||||
.font(.system(size: isGameOver ? resultFontSize * 0.7 : resultFontSize, weight: .black, design: .rounded))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.white, resultColor],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.shadow(color: resultColor.opacity(CasinoDesign.Opacity.heavy), radius: CasinoDesign.Shadow.radiusLarge)
|
||||
.scaleEffect(showText ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
|
||||
.opacity(showText ? CasinoDesign.Scale.normal : 0)
|
||||
|
||||
// Game-specific breakdown content
|
||||
breakdownContent
|
||||
.scaleEffect(showBreakdown ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
|
||||
.opacity(showBreakdown ? CasinoDesign.Scale.normal : 0)
|
||||
|
||||
// Total winnings
|
||||
if totalWinnings != 0 {
|
||||
totalWinningsView
|
||||
.scaleEffect(showTotal ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
|
||||
.opacity(showTotal ? CasinoDesign.Scale.normal : 0)
|
||||
}
|
||||
|
||||
// Button section
|
||||
buttonSection
|
||||
.scaleEffect(showButton ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
|
||||
.opacity(showButton ? CasinoDesign.Scale.normal : 0)
|
||||
.padding(.top, CasinoDesign.Spacing.small)
|
||||
}
|
||||
.padding(.horizontal, CasinoDesign.Spacing.xLarge)
|
||||
.padding(.vertical, CasinoDesign.Spacing.xxLarge)
|
||||
.frame(maxWidth: maxBannerWidth)
|
||||
.background(bannerBackground)
|
||||
.shadow(color: resultColor.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusXXLarge)
|
||||
.padding(.horizontal, CasinoDesign.Spacing.large)
|
||||
.scaleEffect(showBanner ? CasinoDesign.Scale.normal : CasinoDesign.Scale.slightShrink)
|
||||
.opacity(showBanner ? CasinoDesign.Scale.normal : 0)
|
||||
}
|
||||
.onAppear {
|
||||
startAnimations()
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityDescription)
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private var totalWinningsView: some View {
|
||||
HStack(spacing: CasinoDesign.Spacing.small) {
|
||||
Text(String(localized: "TOTAL", bundle: .module))
|
||||
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.accent))
|
||||
|
||||
if totalWinnings > 0 {
|
||||
Text("+\(totalWinnings)")
|
||||
.font(.system(size: totalFontSize, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Text("\(totalWinnings)")
|
||||
.font(.system(size: totalFontSize, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var buttonSection: some View {
|
||||
if isGameOver {
|
||||
// Game Over - show restart button (message is at top)
|
||||
Button {
|
||||
onPlayAgain()
|
||||
} label: {
|
||||
HStack(spacing: CasinoDesign.Spacing.small) {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
Text(String(localized: "Play Again", bundle: .module))
|
||||
}
|
||||
.font(.system(size: buttonFontSize, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
.padding(.horizontal, CasinoDesign.Spacing.xxxLarge + CasinoDesign.Spacing.small)
|
||||
.padding(.vertical, CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall)
|
||||
.background(goldButtonBackground)
|
||||
}
|
||||
} else {
|
||||
// Normal - New Round button
|
||||
Button {
|
||||
onNewRound()
|
||||
} label: {
|
||||
Text(String(localized: "New Round", bundle: .module))
|
||||
.font(.system(size: buttonFontSize, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
.padding(.horizontal, CasinoDesign.Spacing.xxxLarge + CasinoDesign.Spacing.small)
|
||||
.padding(.vertical, CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall)
|
||||
.background(goldButtonBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var goldButtonBackground: some View {
|
||||
Capsule()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.shadow(color: .yellow.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusMedium)
|
||||
}
|
||||
|
||||
private var bannerBackground: some View {
|
||||
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.xxLarge)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.CasinoModal.backgroundLight,
|
||||
Color.CasinoModal.backgroundDark
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.xxLarge)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
resultColor.opacity(CasinoDesign.Opacity.heavy),
|
||||
resultColor.opacity(CasinoDesign.Opacity.light)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: CasinoDesign.LineWidth.thick
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Animations
|
||||
|
||||
private func startAnimations() {
|
||||
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce)) {
|
||||
showBanner = true
|
||||
}
|
||||
|
||||
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay1)) {
|
||||
showText = true
|
||||
}
|
||||
|
||||
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay2)) {
|
||||
showBreakdown = true
|
||||
}
|
||||
|
||||
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay3)) {
|
||||
showTotal = true
|
||||
}
|
||||
|
||||
// Show button after everything else
|
||||
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay3 + CasinoDesign.Animation.staggerDelay1)) {
|
||||
showButton = true
|
||||
}
|
||||
|
||||
// Play game over sound if out of chips
|
||||
if isGameOver {
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
SoundManager.shared.play(.gameOver)
|
||||
}
|
||||
}
|
||||
|
||||
// Announce result to VoiceOver users
|
||||
announceResult()
|
||||
}
|
||||
|
||||
// MARK: - Accessibility
|
||||
|
||||
private var accessibilityDescription: String {
|
||||
var description = resultText
|
||||
|
||||
if totalWinnings > 0 {
|
||||
description += ". " + String(localized: "Total winnings: \(totalWinnings)", bundle: .module)
|
||||
} else if totalWinnings < 0 {
|
||||
description += ". " + String(localized: "Total loss: \(abs(totalWinnings))", bundle: .module)
|
||||
}
|
||||
|
||||
if isGameOver {
|
||||
description += ". " + String(localized: "Game over. You've run out of chips.", bundle: .module)
|
||||
}
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
private func announceResult() {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
AccessibilityNotification.Announcement(accessibilityDescription).post()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Result Breakdown Card
|
||||
|
||||
/// A styled container for bet/hand breakdown content.
|
||||
/// Provides consistent styling across all casino games.
|
||||
public struct ResultBreakdownCard<Content: View>: View {
|
||||
@ViewBuilder public let content: Content
|
||||
|
||||
public init(@ViewBuilder content: () -> Content) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: CasinoDesign.Spacing.xSmall) {
|
||||
content
|
||||
}
|
||||
.padding(.horizontal, CasinoDesign.Spacing.medium)
|
||||
.padding(.vertical, CasinoDesign.Spacing.small)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
|
||||
.fill(Color.white.opacity(CasinoDesign.Opacity.verySubtle))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Result Row
|
||||
|
||||
/// A generic row for displaying a result item with label, optional amount, and status.
|
||||
public struct ResultItemRow: View {
|
||||
public let label: String
|
||||
public let statusText: String
|
||||
public let statusColor: Color
|
||||
public var amount: Int?
|
||||
public var statusIcon: String?
|
||||
|
||||
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = CasinoDesign.BaseFontSize.medium
|
||||
|
||||
public init(
|
||||
label: String,
|
||||
statusText: String,
|
||||
statusColor: Color,
|
||||
amount: Int? = nil,
|
||||
statusIcon: String? = nil
|
||||
) {
|
||||
self.label = label
|
||||
self.statusText = statusText
|
||||
self.statusColor = statusColor
|
||||
self.amount = amount
|
||||
self.statusIcon = statusIcon
|
||||
}
|
||||
|
||||
private var amountText: String? {
|
||||
guard let amount = amount else { return nil }
|
||||
if amount > 0 {
|
||||
return "+\(amount)"
|
||||
} else if amount < 0 {
|
||||
return "\(amount)"
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var amountColor: Color {
|
||||
guard let amount = amount else { return .white }
|
||||
if amount > 0 { return .green }
|
||||
if amount < 0 { return .red }
|
||||
return .blue
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: CasinoDesign.Spacing.small) {
|
||||
// Status icon (if provided)
|
||||
if let icon = statusIcon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundStyle(statusColor)
|
||||
}
|
||||
|
||||
// Label
|
||||
Text(label)
|
||||
.font(.system(size: fontSize, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.heavy))
|
||||
|
||||
Spacer()
|
||||
|
||||
// Amount (if provided)
|
||||
if let amountText = amountText {
|
||||
Text(amountText)
|
||||
.font(.system(size: fontSize, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(amountColor)
|
||||
}
|
||||
|
||||
// Status text
|
||||
Text(statusText)
|
||||
.font(.system(size: fontSize + 2, weight: .bold))
|
||||
.foregroundStyle(statusColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Win Result") {
|
||||
ZStack {
|
||||
Color.CasinoTable.felt
|
||||
.ignoresSafeArea()
|
||||
|
||||
CasinoResultBannerView(
|
||||
resultText: "WIN!",
|
||||
resultColor: .green,
|
||||
totalWinnings: 500,
|
||||
currentBalance: 10500,
|
||||
minBet: 10,
|
||||
breakdownContent: {
|
||||
ResultBreakdownCard {
|
||||
ResultItemRow(
|
||||
label: "Main Hand",
|
||||
statusText: "WIN",
|
||||
statusColor: .green,
|
||||
amount: 500,
|
||||
statusIcon: "checkmark.circle.fill"
|
||||
)
|
||||
}
|
||||
},
|
||||
onNewRound: {},
|
||||
onPlayAgain: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Game Over") {
|
||||
ZStack {
|
||||
Color.CasinoTable.felt
|
||||
.ignoresSafeArea()
|
||||
|
||||
CasinoResultBannerView(
|
||||
resultText: "LOSE",
|
||||
resultColor: .red,
|
||||
totalWinnings: -100,
|
||||
currentBalance: 0,
|
||||
minBet: 10,
|
||||
breakdownContent: {
|
||||
ResultBreakdownCard {
|
||||
ResultItemRow(
|
||||
label: "Main Hand",
|
||||
statusText: "BUST",
|
||||
statusColor: .red,
|
||||
amount: -100,
|
||||
statusIcon: "xmark.circle.fill"
|
||||
)
|
||||
}
|
||||
},
|
||||
onNewRound: {},
|
||||
onPlayAgain: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,7 +75,12 @@ struct GameTableView: View {
|
||||
balance: state.balance,
|
||||
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 }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user