Compare commits

...

9 Commits

21 changed files with 1384 additions and 341 deletions

View File

@ -360,6 +360,17 @@ Color.BettingZone.dragonBonusLight
- If the project requires secrets such as API keys, never include them in the repository. - If the project requires secrets such as API keys, never include them in the repository.
## Documentation instructions
- **Always keep each game's `README.md` file up to date** when adding new functionality or making changes that users or developers need to know about.
- Document new features, settings, or gameplay mechanics in the appropriate game's README.
- Update the README when modifying existing behavior that affects how the game works.
- Include any configuration options, keyboard shortcuts, or special interactions.
- If adding a new game to the workspace, create a comprehensive README following the existing games' format.
- README updates should be part of the same commit as the feature/change they document.
## PR instructions ## PR instructions
- If installed, make sure SwiftLint returns no warnings or errors before committing. - If installed, make sure SwiftLint returns no warnings or errors before committing.
- Verify that the game's README.md reflects any new functionality or behavioral changes.

View File

@ -114,6 +114,206 @@ final class GameState {
Array(roundHistory.suffix(20)) Array(roundHistory.suffix(20))
} }
// MARK: - Hint System
/// The current streak type (player wins, banker wins, or alternating/none).
var currentStreakInfo: (type: GameResult?, count: Int) {
guard !roundHistory.isEmpty else { return (nil, 0) }
var streakType: GameResult?
var count = 0
// Count backwards from most recent, ignoring ties for streak purposes
for result in roundHistory.reversed() {
// Skip ties when counting streaks
if result.result == .tie {
continue
}
if streakType == nil {
streakType = result.result
count = 1
} else if result.result == streakType {
count += 1
} else {
break
}
}
return (streakType, count)
}
/// Distribution of results in the current session.
var resultDistribution: (player: Int, banker: Int, tie: Int) {
let player = roundHistory.filter { $0.result == .playerWins }.count
let banker = roundHistory.filter { $0.result == .bankerWins }.count
let tie = roundHistory.filter { $0.result == .tie }.count
return (player, banker, tie)
}
/// Whether the game is "choppy" (alternating frequently between Player and Banker).
var isChoppy: Bool {
guard roundHistory.count >= 6 else { return false }
// Check last 6 non-tie results for alternating pattern
let recentNonTie = roundHistory.suffix(10).filter { $0.result != .tie }.suffix(6)
guard recentNonTie.count >= 6 else { return false }
var alternations = 0
var previous: GameResult?
for result in recentNonTie {
if let prev = previous, prev != result.result {
alternations += 1
}
previous = result.result
}
// If 4+ alternations in 6 hands, it's choppy
return alternations >= 4
}
/// Hint information including text and style for the shared BettingHintView.
struct HintInfo {
let text: String
let secondaryText: String?
let isStreak: Bool
let isChoppy: Bool
let isBankerHot: Bool
let isPlayerHot: Bool
/// Determines the style for CasinoKit.BettingHintView.
var style: BettingHintStyle {
if isStreak { return .streak }
if isChoppy { return .pattern }
if isBankerHot { return .custom(.red, "chart.line.uptrend.xyaxis") }
if isPlayerHot { return .custom(.blue, "chart.line.uptrend.xyaxis") }
return .neutral
}
}
/// Current betting hint for beginners.
/// Returns nil if hints are disabled or no actionable hint is available.
var currentHint: String? {
currentHintInfo?.text
}
/// Full hint information including style.
var currentHintInfo: HintInfo? {
guard settings.showHints else { return nil }
guard currentPhase == .betting else { return nil }
// If no history, give the fundamental advice
if roundHistory.isEmpty {
return HintInfo(
text: String(localized: "Banker has the lowest house edge (1.06%)"),
secondaryText: nil,
isStreak: false,
isChoppy: false,
isBankerHot: false,
isPlayerHot: false
)
}
let streak = currentStreakInfo
let dist = resultDistribution
let total = dist.player + dist.banker + dist.tie
// Calculate percentages if we have enough data
if total >= 5 {
let bankerPct = Double(dist.banker) / Double(total) * 100
let playerPct = Double(dist.player) / Double(total) * 100
let trendText = "P: \(Int(playerPct))% | B: \(Int(bankerPct))%"
// Strong streak (4+): suggest following or note it
if let streakType = streak.type, streak.count >= 4 {
let streakName = streakType == .bankerWins ?
String(localized: "Banker") : String(localized: "Player")
return HintInfo(
text: String(localized: "\(streakName) streak: \(streak.count) in a row"),
secondaryText: trendText,
isStreak: true,
isChoppy: false,
isBankerHot: false,
isPlayerHot: false
)
}
// Choppy game: note the pattern
if isChoppy {
return HintInfo(
text: String(localized: "Choppy shoe - results alternating"),
secondaryText: trendText,
isStreak: false,
isChoppy: true,
isBankerHot: false,
isPlayerHot: false
)
}
// Significant imbalance (15%+ difference)
if bankerPct > playerPct + 15 {
return HintInfo(
text: String(localized: "Banker running hot (\(Int(bankerPct))%)"),
secondaryText: nil,
isStreak: false,
isChoppy: false,
isBankerHot: true,
isPlayerHot: false
)
} else if playerPct > bankerPct + 15 {
return HintInfo(
text: String(localized: "Player running hot (\(Int(playerPct))%)"),
secondaryText: nil,
isStreak: false,
isChoppy: false,
isBankerHot: false,
isPlayerHot: true
)
}
}
// Default hint: remind about odds
return HintInfo(
text: String(localized: "Banker bet has lowest house edge"),
secondaryText: nil,
isStreak: false,
isChoppy: false,
isBankerHot: false,
isPlayerHot: false
)
}
/// Short trend summary for the top bar or compact display.
var trendSummary: String? {
guard settings.showHints else { return nil }
guard !roundHistory.isEmpty else { return nil }
let streak = currentStreakInfo
if let streakType = streak.type, streak.count >= 2 {
let letter = streakType == .bankerWins ? "B" : "P"
return "\(letter)×\(streak.count)"
}
return nil
}
/// Warning message for high house edge bets.
func warningForBet(_ type: BetType) -> String? {
guard settings.showHints else { return nil }
switch type {
case .tie:
return String(localized: "Tie has 14% house edge")
case .playerPair, .bankerPair:
return String(localized: "Pair bets have ~10% house edge")
case .dragonBonusPlayer, .dragonBonusBanker:
return String(localized: "Dragon Bonus: high risk, high reward")
default:
return nil
}
}
// MARK: - Animation Timing (based on settings) // MARK: - Animation Timing (based on settings)
private var dealDelay: Duration { private var dealDelay: Duration {
@ -318,6 +518,13 @@ final class GameState {
return bet.amount < minBet return bet.amount < minBet
} }
/// Which main bet is placed - nil if no main bet, true if Player, false if Banker.
/// Used to determine card layout ordering (betted hand appears on bottom).
var bettedOnPlayer: Bool? {
guard let bet = mainBet else { return nil }
return bet.type == .player
}
/// Amount needed to reach the minimum bet. /// Amount needed to reach the minimum bet.
var amountNeededForMinimum: Int { var amountNeededForMinimum: Int {
guard let bet = mainBet else { return minBet } guard let bet = mainBet else { return minBet }
@ -403,8 +610,14 @@ final class GameState {
bankerHadPair = false bankerHadPair = false
betResults = [] betResults = []
// Deal initial cards // Change to dealing phase - triggers layout animation (horizontal to vertical)
currentPhase = .dealingInitial currentPhase = .dealingInitial
// Wait for layout animation to complete before dealing cards
if settings.showAnimations {
try? await Task.sleep(for: .seconds(1))
}
let initialCards = engine.dealInitialCards() let initialCards = engine.dealInitialCards()
// Check if animations are enabled // Check if animations are enabled

View File

@ -133,6 +133,9 @@ final class GameSettings {
/// Whether to show the history road map. /// Whether to show the history road map.
var showHistory: Bool = true var showHistory: Bool = true
/// Whether to show betting hints and recommendations.
var showHints: Bool = true
// MARK: - Sound Settings // MARK: - Sound Settings
/// Whether sound effects are enabled. /// Whether sound effects are enabled.
@ -154,6 +157,7 @@ final class GameSettings {
static let dealingSpeed = "settings.dealingSpeed" static let dealingSpeed = "settings.dealingSpeed"
static let showCardsRemaining = "settings.showCardsRemaining" static let showCardsRemaining = "settings.showCardsRemaining"
static let showHistory = "settings.showHistory" static let showHistory = "settings.showHistory"
static let showHints = "settings.showHints"
static let soundEnabled = "settings.soundEnabled" static let soundEnabled = "settings.soundEnabled"
static let hapticsEnabled = "settings.hapticsEnabled" static let hapticsEnabled = "settings.hapticsEnabled"
static let soundVolume = "settings.soundVolume" static let soundVolume = "settings.soundVolume"
@ -237,6 +241,10 @@ final class GameSettings {
self.showHistory = defaults.bool(forKey: Keys.showHistory) self.showHistory = defaults.bool(forKey: Keys.showHistory)
} }
if defaults.object(forKey: Keys.showHints) != nil {
self.showHints = defaults.bool(forKey: Keys.showHints)
}
if defaults.object(forKey: Keys.soundEnabled) != nil { if defaults.object(forKey: Keys.soundEnabled) != nil {
self.soundEnabled = defaults.bool(forKey: Keys.soundEnabled) self.soundEnabled = defaults.bool(forKey: Keys.soundEnabled)
} }
@ -284,6 +292,10 @@ final class GameSettings {
self.showHistory = iCloudStore.bool(forKey: Keys.showHistory) self.showHistory = iCloudStore.bool(forKey: Keys.showHistory)
} }
if iCloudStore.object(forKey: Keys.showHints) != nil {
self.showHints = iCloudStore.bool(forKey: Keys.showHints)
}
if iCloudStore.object(forKey: Keys.soundEnabled) != nil { if iCloudStore.object(forKey: Keys.soundEnabled) != nil {
self.soundEnabled = iCloudStore.bool(forKey: Keys.soundEnabled) self.soundEnabled = iCloudStore.bool(forKey: Keys.soundEnabled)
} }
@ -308,6 +320,7 @@ final class GameSettings {
defaults.set(dealingSpeed, forKey: Keys.dealingSpeed) defaults.set(dealingSpeed, forKey: Keys.dealingSpeed)
defaults.set(showCardsRemaining, forKey: Keys.showCardsRemaining) defaults.set(showCardsRemaining, forKey: Keys.showCardsRemaining)
defaults.set(showHistory, forKey: Keys.showHistory) defaults.set(showHistory, forKey: Keys.showHistory)
defaults.set(showHints, forKey: Keys.showHints)
defaults.set(soundEnabled, forKey: Keys.soundEnabled) defaults.set(soundEnabled, forKey: Keys.soundEnabled)
defaults.set(hapticsEnabled, forKey: Keys.hapticsEnabled) defaults.set(hapticsEnabled, forKey: Keys.hapticsEnabled)
defaults.set(soundVolume, forKey: Keys.soundVolume) defaults.set(soundVolume, forKey: Keys.soundVolume)
@ -321,6 +334,7 @@ final class GameSettings {
iCloudStore.set(dealingSpeed, forKey: Keys.dealingSpeed) iCloudStore.set(dealingSpeed, forKey: Keys.dealingSpeed)
iCloudStore.set(showCardsRemaining, forKey: Keys.showCardsRemaining) iCloudStore.set(showCardsRemaining, forKey: Keys.showCardsRemaining)
iCloudStore.set(showHistory, forKey: Keys.showHistory) iCloudStore.set(showHistory, forKey: Keys.showHistory)
iCloudStore.set(showHints, forKey: Keys.showHints)
iCloudStore.set(soundEnabled, forKey: Keys.soundEnabled) iCloudStore.set(soundEnabled, forKey: Keys.soundEnabled)
iCloudStore.set(hapticsEnabled, forKey: Keys.hapticsEnabled) iCloudStore.set(hapticsEnabled, forKey: Keys.hapticsEnabled)
iCloudStore.set(Double(soundVolume), forKey: Keys.soundVolume) iCloudStore.set(Double(soundVolume), forKey: Keys.soundVolume)
@ -337,6 +351,7 @@ final class GameSettings {
dealingSpeed = 1.0 dealingSpeed = 1.0
showCardsRemaining = true showCardsRemaining = true
showHistory = true showHistory = true
showHints = true
soundEnabled = true soundEnabled = true
hapticsEnabled = true hapticsEnabled = true
soundVolume = 1.0 soundVolume = 1.0

View File

@ -23,6 +23,42 @@
} }
} }
}, },
"%@ streak of %lld" : {
"comment" : "A label describing the type of streak and its count. The first argument is the type of streak (\"Banker\" or \"Player\"). The second argument is the count of the streak.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@ streak of %2$lld"
}
}
}
},
"%@ streak: %lld in a row" : {
"comment" : "Text displayed as a hint in the game UI, providing information about a streak of wins for either the Player or Banker. The argument is the name of the player/banker involved in the streak, and the second argument is the count of consecutive wins.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@ streak: %2$lld in a row"
}
}
}
},
"%@×%lld" : {
"comment" : "A text view displaying the streak count and type (e.g., \"3×Banker\"). The first argument is the streak count. The second argument is the type of streak (\"Banker\" or \"Player\").",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@×%2$lld"
}
}
}
},
"%lld" : { "%lld" : {
"comment" : "The number of rounds a player has played in the game.", "comment" : "The number of rounds a player has played in the game.",
"localizations" : { "localizations" : {
@ -458,6 +494,7 @@
} }
}, },
"Add $%lld more to meet minimum" : { "Add $%lld more to meet minimum" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -684,6 +721,9 @@
} }
} }
} }
},
"B: %lld%%" : {
}, },
"Baccarat has one of the lowest house edges in the casino." : { "Baccarat has one of the lowest house edges in the casino." : {
"comment" : "Description of the house edge of baccarat.", "comment" : "Description of the house edge of baccarat.",
@ -891,6 +931,10 @@
} }
} }
}, },
"Banker bet has lowest house edge" : {
"comment" : "Default hint in the game, reminding players about the house edge of the banker bet.",
"isCommentAutoGenerated" : true
},
"Banker bet has the lowest house edge (1.06%)." : { "Banker bet has the lowest house edge (1.06%)." : {
"comment" : "Description of the house edge for the Banker bet in the Rules Help view.", "comment" : "Description of the house edge for the Banker bet in the Rules Help view.",
"localizations" : { "localizations" : {
@ -960,6 +1004,14 @@
} }
} }
}, },
"Banker has the lowest house edge (1.06%)" : {
"comment" : "Hint text for beginners about the house edge of the banker bet in a baccarat round.",
"isCommentAutoGenerated" : true
},
"Banker running hot (%lld%%)" : {
"comment" : "A hint to place a bet on the Banker based on the calculated house edge. The argument is the percentage of the house edge.",
"isCommentAutoGenerated" : true
},
"Bet on which hand will win: Player, Banker, or Tie." : { "Bet on which hand will win: Player, Banker, or Tie." : {
"comment" : "Text describing the objective of the baccarat game.", "comment" : "Text describing the objective of the baccarat game.",
"localizations" : { "localizations" : {
@ -982,6 +1034,9 @@
} }
} }
} }
},
"Betting tips and trend analysis" : {
}, },
"Blackjack" : { "Blackjack" : {
"comment" : "The name of a blackjack game.", "comment" : "The name of a blackjack game.",
@ -1167,6 +1222,10 @@
} }
} }
}, },
"Choppy shoe - results alternating" : {
"comment" : "Hint text indicating that the current shoe is \"choppy\", with results alternating between Player and Banker.",
"isCommentAutoGenerated" : true
},
"Clear" : { "Clear" : {
"comment" : "The label of a button that clears all current bets in the game.", "comment" : "The label of a button that clears all current bets in the game.",
"localizations" : { "localizations" : {
@ -1280,6 +1339,7 @@
}, },
"Deal" : { "Deal" : {
"comment" : "The label of a button that deals cards in a game.", "comment" : "The label of a button that deals cards in a game.",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1303,6 +1363,7 @@
}, },
"Dealing..." : { "Dealing..." : {
"comment" : "A placeholder text shown while a game is being dealt.", "comment" : "A placeholder text shown while a game is being dealt.",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1484,6 +1545,10 @@
} }
} }
}, },
"Dragon Bonus: high risk, high reward" : {
"comment" : "Warning text for dragon bonus bets, advising players to be cautious.",
"isCommentAutoGenerated" : true
},
"Example: 5♥ + 5♣ = Pair (wins!)" : { "Example: 5♥ + 5♣ = Pair (wins!)" : {
"comment" : "Example of a pair bet winning.", "comment" : "Example of a pair bet winning.",
"localizations" : { "localizations" : {
@ -2512,6 +2577,14 @@
} }
} }
}, },
"P: %lld%%" : {
"comment" : "Labels indicating the percentage of Player, Banker, and Tie outcomes in a game's result distribution.",
"isCommentAutoGenerated" : true
},
"Pair bets have ~10% house edge" : {
"comment" : "Warning message for high house edge pair bets.",
"isCommentAutoGenerated" : true
},
"Pair bets have ~10% house edge." : { "Pair bets have ~10% house edge." : {
"comment" : "Description of the house edge of a pair bet in the Rules Help view.", "comment" : "Description of the house edge of a pair bet in the Rules Help view.",
"localizations" : { "localizations" : {
@ -2673,6 +2746,18 @@
} }
} }
}, },
"Player %lld%%, Banker %lld%%, Tie %lld%%" : {
"comment" : "A label describing the percentage of times the player, banker, or tie result occurred in the last spin of the game.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Player %1$lld%%, Banker %2$lld%%, Tie %3$lld%%"
}
}
}
},
"Player Bet: Pays 1:1 (even money)" : { "Player Bet: Pays 1:1 (even money)" : {
"comment" : "Description of the payout for a Player Bet in the Rules Help view.", "comment" : "Description of the payout for a Player Bet in the Rules Help view.",
"localizations" : { "localizations" : {
@ -2719,6 +2804,10 @@
} }
} }
}, },
"Player running hot (%lld%%)" : {
"comment" : "A hint to place a bet on the Player, given a significant house edge in favor of the Player. The percentage is included as a context hint.",
"isCommentAutoGenerated" : true
},
"Player with 0-5: Draws a third card" : { "Player with 0-5: Draws a third card" : {
"comment" : "Description of the action for the Player when their third card is 0-5.", "comment" : "Description of the action for the Player when their third card is 0-5.",
"localizations" : { "localizations" : {
@ -2902,6 +2991,10 @@
} }
} }
}, },
"Result distribution" : {
"comment" : "A label describing the view that shows the distribution of betting results.",
"isCommentAutoGenerated" : true
},
"Roulette" : { "Roulette" : {
"comment" : "The name of a roulette game.", "comment" : "The name of a roulette game.",
"localizations" : { "localizations" : {
@ -3039,6 +3132,9 @@
} }
} }
} }
},
"Show Hints" : {
}, },
"Show History" : { "Show History" : {
"comment" : "Toggle label for showing game history.", "comment" : "Toggle label for showing game history.",
@ -3245,6 +3341,10 @@
} }
} }
}, },
"Streak" : {
"comment" : "An accessibility label for the streak badge.",
"isCommentAutoGenerated" : true
},
"Sync Now" : { "Sync Now" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -3289,6 +3389,10 @@
} }
} }
}, },
"T: %lld%%" : {
"comment" : "A label indicating that there are ties in the distribution.",
"isCommentAutoGenerated" : true
},
"TABLE LIMITS" : { "TABLE LIMITS" : {
"comment" : "Section header for table limits settings.", "comment" : "Section header for table limits settings.",
"localizations" : { "localizations" : {
@ -3541,6 +3645,10 @@
} }
} }
}, },
"Tie has 14% house edge" : {
"comment" : "Warning message for a tie bet, explaining the high house edge.",
"isCommentAutoGenerated" : true
},
"TOTAL" : { "TOTAL" : {
"comment" : "A label displayed next to the total winnings in the result banner.", "comment" : "A label displayed next to the total winnings in the result banner.",
"extractionState" : "stale", "extractionState" : "stale",

View File

@ -14,7 +14,7 @@ import CasinoKit
enum Design { enum Design {
/// Set to true to show layout debug borders on views /// Set to true to show layout debug borders on views
static let showDebugBorders = false static let showDebugBorders = true
// MARK: - Shared Constants (from CasinoKit) // MARK: - Shared Constants (from CasinoKit)
@ -28,6 +28,8 @@ enum Design {
typealias MinScaleFactor = CasinoDesign.MinScaleFactor typealias MinScaleFactor = CasinoDesign.MinScaleFactor
typealias BaseFontSize = CasinoDesign.BaseFontSize typealias BaseFontSize = CasinoDesign.BaseFontSize
typealias IconSize = CasinoDesign.IconSize typealias IconSize = CasinoDesign.IconSize
typealias Toast = CasinoDesign.Toast
typealias HintSize = CasinoDesign.HintSize
// MARK: - Baccarat-Specific Sizes (use CasinoDesign.Size for shared values) // MARK: - Baccarat-Specific Sizes (use CasinoDesign.Size for shared values)
@ -49,6 +51,9 @@ enum Design {
// Labels // Labels
static let labelFontSize: CGFloat = 14 static let labelFontSize: CGFloat = 14
static let labelRowHeight: CGFloat = 30 static let labelRowHeight: CGFloat = 30
// Buttons
static let bettingButtonsContainerHeight: CGFloat = 70
} }
// MARK: - Card Deal Animation // MARK: - Card Deal Animation

View File

@ -28,8 +28,7 @@ struct ActionButtonsView: View {
private let buttonFontSize: CGFloat = Design.BaseFontSize.xLarge private let buttonFontSize: CGFloat = Design.BaseFontSize.xLarge
private let iconSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall private let iconSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall
private let statusFontSize: CGFloat = Design.BaseFontSize.medium private let containerHeight: CGFloat = Design.Size.bettingButtonsContainerHeight
private let containerHeight: CGFloat = 70
// MARK: - Body // MARK: - Body
@ -45,8 +44,6 @@ struct ActionButtonsView: View {
bettingButtons bettingButtons
} else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner { } else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner {
newRoundButton newRoundButton
} else if !gameState.showResultBanner {
dealingIndicator
} }
} }
.animation(.easeInOut(duration: Design.Animation.quick), value: gameState.currentPhase) .animation(.easeInOut(duration: Design.Animation.quick), value: gameState.currentPhase)
@ -57,104 +54,13 @@ struct ActionButtonsView: View {
// MARK: - Private Views // MARK: - Private Views
private var bettingButtons: some View { private var bettingButtons: some View {
VStack(spacing: Design.Spacing.small) { BettingActionsView(
// Show hint if main bet is below minimum hasBet: !gameState.currentBets.isEmpty,
if gameState.isMainBetBelowMinimum { canDeal: gameState.canDeal,
Text(String(localized: "Add $\(gameState.amountNeededForMinimum) more to meet minimum")) amountNeededForMinimum: gameState.isMainBetBelowMinimum ? gameState.amountNeededForMinimum : nil,
.font(.system(size: Design.BaseFontSize.small, weight: .medium)) onClear: onClear,
.foregroundStyle(.orange) onDeal: onDeal
.transition(.opacity.combined(with: .scale(scale: 0.8)))
}
HStack(spacing: Design.Spacing.medium) {
clearButton
dealButton
}
}
.animation(.easeInOut(duration: Design.Animation.standard), value: gameState.isMainBetBelowMinimum)
}
private var dealingIndicator: some View {
HStack(spacing: Design.Spacing.xSmall) {
ProgressView()
.tint(.white)
.scaleEffect(0.8)
Text("Dealing...")
.font(.system(size: statusFontSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.heavy))
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.relaxed)
}
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.medium)
}
@ViewBuilder
private var clearButton: some View {
if isAccessibilitySize {
Button("Clear", systemImage: "xmark.circle", action: onClear)
.labelStyle(.iconOnly)
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(.white)
.padding(Design.Spacing.medium + Design.Spacing.xxSmall)
.background(
Circle()
.fill(Color.CasinoButton.destructive)
) )
.opacity(gameState.currentBets.isEmpty ? Design.Opacity.disabled : 1.0)
.disabled(gameState.currentBets.isEmpty)
} else {
Button("Clear", systemImage: "xmark.circle", action: onClear)
.labelStyle(.titleAndIcon)
.font(.system(size: buttonFontSize, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
.background(
Capsule()
.fill(Color.CasinoButton.destructive)
)
.opacity(gameState.currentBets.isEmpty ? Design.Opacity.disabled : 1.0)
.disabled(gameState.currentBets.isEmpty)
}
}
@ViewBuilder
private var dealButton: some View {
let buttonBackground = LinearGradient(
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
startPoint: .top,
endPoint: .bottom
)
if isAccessibilitySize {
Button("Deal", systemImage: "play.fill", action: onDeal)
.labelStyle(.iconOnly)
.font(.system(size: iconSize, weight: .bold))
.foregroundStyle(.black)
.padding(Design.Spacing.medium + Design.Spacing.xxSmall)
.background(
Circle()
.fill(buttonBackground)
)
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium)
.opacity(gameState.canDeal ? 1.0 : Design.Opacity.disabled)
.disabled(!gameState.canDeal)
} else {
Button("Deal", systemImage: "play.fill", action: onDeal)
.labelStyle(.titleAndIcon)
.font(.system(size: buttonFontSize, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
.background(
Capsule()
.fill(buttonBackground)
)
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium)
.opacity(gameState.canDeal ? 1.0 : Design.Opacity.disabled)
.disabled(!gameState.canDeal)
}
} }
@ViewBuilder @ViewBuilder

View File

@ -17,6 +17,9 @@ struct GameTableView: View {
@State private var showRules = false @State private var showRules = false
@State private var showStats = false @State private var showStats = false
/// Screen size for card sizing (measured from TableBackgroundView)
@State private var screenSize: CGSize = CGSize(width: 375, height: 667)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass @Environment(\.verticalSizeClass) private var verticalSizeClass
@ -37,26 +40,21 @@ struct GameTableView: View {
isLandscape ? Design.Spacing.medium : Design.Spacing.xSmall isLandscape ? Design.Spacing.medium : Design.Spacing.xSmall
} }
/// Minimum spacer height - smaller in landscape to fit content
private var minSpacerHeight: CGFloat {
isLandscape ? 0 : Design.Spacing.xSmall
}
/// Smaller spacer height - reduced in landscape /// Smaller spacer height - reduced in landscape
private var smallSpacerHeight: CGFloat { private var smallSpacerHeight: CGFloat {
isLandscape ? Design.Spacing.xxSmall : Design.Spacing.small isLandscape ? Design.Spacing.xxSmall : Design.Spacing.small
} }
/// Medium spacer height - reduced in landscape
private var mediumSpacerHeight: CGFloat {
isLandscape ? Design.Spacing.xSmall : Design.Spacing.medium
}
/// Maximum width for game content on large screens /// Maximum width for game content on large screens
private var maxContentWidth: CGFloat { private var maxContentWidth: CGFloat {
isLandscape ? CasinoDesign.Size.maxContentWidthLandscape : CasinoDesign.Size.maxContentWidthPortrait isLandscape ? CasinoDesign.Size.maxContentWidthLandscape : CasinoDesign.Size.maxContentWidthPortrait
} }
/// Whether we're on a small screen (like iPhone SE) where space is tight
private var isSmallScreen: Bool {
screenSize.height < 700
}
private var state: GameState { private var state: GameState {
gameState ?? GameState(settings: settings) gameState ?? GameState(settings: settings)
} }
@ -73,6 +71,11 @@ struct GameTableView: View {
state.lastResult == .tie state.lastResult == .tie
} }
/// Whether we're in a dealing/result phase (vertical layout) vs betting phase (horizontal)
private var isDealing: Bool {
state.currentPhase != .betting
}
// Use global debug flag from Design constants // Use global debug flag from Design constants
private var showDebugBorders: Bool { Design.showDebugBorders } private var showDebugBorders: Bool { Design.showDebugBorders }
@ -81,8 +84,13 @@ struct GameTableView: View {
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
ZStack { ZStack {
// Table background (from CasinoKit) // Table background - measures screen size for card sizing
TableBackgroundView() TableBackgroundView()
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { size in
screenSize = size
}
// Main content // Main content
mainContent(geometry: geometry) mainContent(geometry: geometry)
@ -195,7 +203,10 @@ struct GameTableView: View {
bankerIsWinner: bankerIsWinner, bankerIsWinner: bankerIsWinner,
isTie: isTie, isTie: isTie,
showAnimations: settings.showAnimations, showAnimations: settings.showAnimations,
dealingSpeed: settings.dealingSpeed dealingSpeed: settings.dealingSpeed,
bettedOnPlayer: state.bettedOnPlayer,
isDealing: isDealing,
screenSize: screenSize
) )
.frame(maxWidth: maxContentWidth) .frame(maxWidth: maxContentWidth)
.padding(.horizontal, Design.Spacing.medium) .padding(.horizontal, Design.Spacing.medium)
@ -212,19 +223,36 @@ struct GameTableView: View {
.padding(.horizontal, Design.Spacing.medium) .padding(.horizontal, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .blue, label: "BetTable") .debugBorder(showDebugBorders, color: .blue, label: "BetTable")
// Betting hint (static, below table, above chips)
if let hintInfo = state.currentHintInfo {
BettingHintView(
hint: hintInfo.text,
secondaryInfo: hintInfo.secondaryText,
style: hintInfo.style
)
.transition(.opacity)
.padding(.vertical, Design.Spacing.small)
.debugBorder(showDebugBorders, color: .purple, label: "Hint")
}
Spacer(minLength: Design.Spacing.xSmall) Spacer(minLength: Design.Spacing.xSmall)
// Chip selector - full width so all chips are tappable // Chip selector - only shown during betting phase
if state.currentPhase == .betting && !state.showResultBanner {
ChipSelectorView( ChipSelectorView(
selectedChip: $selectedChip, selectedChip: $selectedChip,
balance: state.balance, balance: state.balance,
currentBet: state.totalBetAmount, currentBet: state.totalBetAmount,
maxBet: state.maxBet maxBet: state.maxBet
) )
.transition(.opacity.combined(with: .move(edge: .bottom)))
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector") .debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
}
Spacer(minLength: Design.Spacing.xSmall) Spacer(minLength: Design.Spacing.xSmall)
// Action buttons - hidden on small screens during dealing to save space
if !isDealing {
// Action buttons // Action buttons
ActionButtonsView( ActionButtonsView(
gameState: state, gameState: state,
@ -241,14 +269,16 @@ struct GameTableView: View {
.padding(.bottom, Design.Spacing.small) .padding(.bottom, Design.Spacing.small)
.debugBorder(showDebugBorders, color: .green, label: "ActionBtns") .debugBorder(showDebugBorders, color: .green, label: "ActionBtns")
} }
}
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.animation(.easeInOut(duration: Design.Animation.quick), value: state.currentPhase)
.debugBorder(showDebugBorders, color: .white, label: "GameContent") .debugBorder(showDebugBorders, color: .white, label: "GameContent")
} }
} }
.safeAreaPadding(.bottom, Design.Spacing.small) .safeAreaPadding(.bottom, Design.Spacing.small)
} }
/// Portrait layout with RoadMap inline /// Portrait layout - vertical card layout, no RoadMap (shown only in landscape)
private var portraitLayout: some View { private var portraitLayout: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Top bar with balance and info (from CasinoKit) // Top bar with balance and info (from CasinoKit)
@ -262,10 +292,7 @@ struct GameTableView: View {
) )
.debugBorder(showDebugBorders, color: .cyan, label: "TopBar") .debugBorder(showDebugBorders, color: .cyan, label: "TopBar")
Spacer(minLength: minSpacerHeight) // Cards display area - animates from horizontal to vertical when dealing
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer1")
// Cards display area
CardsDisplayArea( CardsDisplayArea(
playerCards: state.visiblePlayerCards, playerCards: state.visiblePlayerCards,
bankerCards: state.visibleBankerCards, bankerCards: state.visibleBankerCards,
@ -277,25 +304,28 @@ struct GameTableView: View {
bankerIsWinner: bankerIsWinner, bankerIsWinner: bankerIsWinner,
isTie: isTie, isTie: isTie,
showAnimations: settings.showAnimations, showAnimations: settings.showAnimations,
dealingSpeed: settings.dealingSpeed dealingSpeed: settings.dealingSpeed,
bettedOnPlayer: state.bettedOnPlayer,
isDealing: isDealing,
screenSize: screenSize
) )
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
.padding(.horizontal, Design.Spacing.medium) .padding(.horizontal, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .red, label: "CardsArea") .debugBorder(showDebugBorders, color: .red, label: "CardsArea")
Spacer(minLength: minSpacerHeight) Spacer(minLength: smallSpacerHeight)
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer2") .debugBorder(showDebugBorders, color: .yellow, label: "Spacer2")
// Road map history // Road map history - show in portrait before deal on larger screens
if settings.showHistory { if settings.showHistory && !isSmallScreen && !isDealing {
RoadMapView(results: state.recentResults) RoadMapView(results: state.recentResults)
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
.padding(.horizontal, Design.Spacing.medium) .padding(.horizontal, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .orange, label: "RoadMap") .debugBorder(showDebugBorders, color: .orange, label: "RoadMap")
}
Spacer(minLength: smallSpacerHeight) Spacer(minLength: smallSpacerHeight)
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer3") .debugBorder(showDebugBorders, color: .yellow, label: "Spacer3")
}
// Betting table // Betting table
BettingTableView( BettingTableView(
@ -306,22 +336,35 @@ struct GameTableView: View {
.padding(.horizontal, Design.Spacing.medium) .padding(.horizontal, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .blue, label: "BetTable") .debugBorder(showDebugBorders, color: .blue, label: "BetTable")
Spacer(minLength: mediumSpacerHeight) // Betting hint (static, below table, above chips)
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer4") if let hintInfo = state.currentHintInfo {
BettingHintView(
hint: hintInfo.text,
secondaryInfo: hintInfo.secondaryText,
style: hintInfo.style
)
.transition(.opacity)
.padding(.vertical, Design.Spacing.small)
.debugBorder(showDebugBorders, color: .purple, label: "Hint")
}
// Chip selector - full width so all chips are tappable // Chip selector - only shown during betting phase
if state.currentPhase == .betting && !state.showResultBanner {
ChipSelectorView( ChipSelectorView(
selectedChip: $selectedChip, selectedChip: $selectedChip,
balance: state.balance, balance: state.balance,
currentBet: state.totalBetAmount, currentBet: state.totalBetAmount,
maxBet: state.maxBet maxBet: state.maxBet
) )
.transition(.opacity.combined(with: .move(edge: .bottom)))
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector") .debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
}
Spacer(minLength: smallSpacerHeight) // Action buttons - hidden on small screens during dealing to save space
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer5") if isSmallScreen && isDealing {
Spacer()
// Action buttons .frame(height: 5)
} else {
ActionButtonsView( ActionButtonsView(
gameState: state, gameState: state,
onDeal: { onDeal: {
@ -337,6 +380,8 @@ struct GameTableView: View {
.padding(.bottom, bottomPadding) .padding(.bottom, bottomPadding)
.debugBorder(showDebugBorders, color: .green, label: "ActionBtns") .debugBorder(showDebugBorders, color: .green, label: "ActionBtns")
} }
}
.animation(.easeInOut(duration: Design.Animation.quick), value: state.currentPhase)
.safeAreaPadding(.bottom) .safeAreaPadding(.bottom)
.debugBorder(showDebugBorders, color: .white, label: "MainContent") .debugBorder(showDebugBorders, color: .white, label: "MainContent")
} }

View File

@ -92,6 +92,16 @@ struct SettingsView: View {
isOn: $settings.showHistory, isOn: $settings.showHistory,
accentColor: accent accentColor: accent
) )
Divider()
.background(Color.white.opacity(Design.Opacity.subtle))
SettingsToggle(
title: String(localized: "Show Hints"),
subtitle: String(localized: "Betting tips and trend analysis"),
isOn: $settings.showHints,
accentColor: accent
)
} }
// 5. Sound & Haptics // 5. Sound & Haptics

View File

@ -3,12 +3,15 @@
// Baccarat // Baccarat
// //
// The cards display area showing both Player and Banker hands. // The cards display area showing both Player and Banker hands.
// Animates from side-by-side (betting) to vertical stack (dealing).
// //
import SwiftUI import SwiftUI
import CasinoKit import CasinoKit
/// The cards display area showing both hands. /// The cards display area showing both hands.
/// - Betting phase: Horizontal side-by-side layout (Player | Banker)
/// - Dealing phase: Vertical stack with betted hand on bottom
struct CardsDisplayArea: View { struct CardsDisplayArea: View {
let playerCards: [Card] let playerCards: [Card]
let bankerCards: [Card] let bankerCards: [Card]
@ -21,38 +24,87 @@ struct CardsDisplayArea: View {
let isTie: Bool let isTie: Bool
let showAnimations: Bool let showAnimations: Bool
let dealingSpeed: Double let dealingSpeed: Double
/// Which main bet is placed - nil if no main bet, true if Player, false if Banker.
let bettedOnPlayer: Bool?
/// Whether the game is in dealing/result phase (vertical layout) or betting phase (horizontal).
let isDealing: Bool
/// Full screen size for calculating card width in dealing mode.
let screenSize: CGSize
// MARK: - State // MARK: - State
@State private var containerWidth: CGFloat = 300 @State private var containerWidth: CGFloat = 300
@Namespace private var animation
// MARK: - Environment // MARK: - Environment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
// MARK: - Computed Properties // MARK: - Computed Properties
/// Whether we're on a large screen (iPad)
private var isLargeScreen: Bool { private var isLargeScreen: Bool {
horizontalSizeClass == .regular horizontalSizeClass == .regular
} }
// Use global debug flag from Design constants /// Whether we're in landscape mode
private var isLandscape: Bool {
verticalSizeClass == .compact || (isLargeScreen && screenSize.width > screenSize.height)
}
private var showDebugBorders: Bool { Design.showDebugBorders } private var showDebugBorders: Bool { Design.showDebugBorders }
/// Label font size - larger on iPad
private var labelFontSize: CGFloat { private var labelFontSize: CGFloat {
isLargeScreen ? 18 : Design.Size.labelFontSize isLargeScreen ? 18 : Design.Size.labelFontSize
} }
/// Minimum height for label row - larger on iPad
private var labelRowMinHeight: CGFloat { private var labelRowMinHeight: CGFloat {
isLargeScreen ? 40 : Design.Size.labelRowHeight isLargeScreen ? 40 : Design.Size.labelRowHeight
} }
/// Spacing between PLAYER and BANKER hands - reduced on smaller screens /// Whether Player hand should be on bottom in vertical mode.
private var playerOnBottom: Bool {
bettedOnPlayer ?? true
}
/// Spacing between hands
private var handsSpacing: CGFloat { private var handsSpacing: CGFloat {
isLargeScreen ? Design.Spacing.xxxLarge : Design.Spacing.large if isDealing {
return isLargeScreen ? Design.Spacing.large : Design.Spacing.medium
} else {
return isLargeScreen ? Design.Spacing.xxxLarge : Design.Spacing.large
}
}
// MARK: - Card Width Calculations
/// Card section width for horizontal (betting) mode
private var horizontalHandWidth: CGFloat {
let spacing = isLargeScreen ? Design.Spacing.xxxLarge : Design.Spacing.large
return max(100, (containerWidth - spacing) / 2)
}
/// Card section width for vertical (dealing) mode - matches Blackjack sizing
/// Uses screen height as base (like Blackjack), which is smaller in landscape
private var verticalHandWidth: CGFloat {
// Use screen height (smaller dimension in landscape = smaller cards)
let height = screenSize.height
guard height > 100 else { return horizontalHandWidth }
// Blackjack uses 0.18, but in landscape we may need slightly smaller
let percentage: CGFloat = isLandscape ? 0.175 : height < 700 ? 0.14 : 0.18
let cardWidth = height * percentage
// CompactHandView: cardWidth = containerWidth / divisor
let overlapRatio: CGFloat = -0.45
let maxCards: CGFloat = 3
let divisor = 1 + (maxCards - 1) * (1 + overlapRatio)
return cardWidth * divisor
}
/// Current hand section width based on mode
private var handSectionWidth: CGFloat {
isDealing ? verticalHandWidth : horizontalHandWidth
} }
// MARK: - Accessibility // MARK: - Accessibility
@ -89,22 +141,43 @@ struct CardsDisplayArea: View {
return visibleCards.joined(separator: ", ") + ". " + String(format: format, bankerValue) return visibleCards.joined(separator: ", ") + ". " + String(format: format, bankerValue)
} }
/// Calculate hand section width from total container width
private var handSectionWidth: CGFloat {
(containerWidth - handsSpacing) / 2
}
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
HStack(spacing: handsSpacing) { // Use different layouts but keep view identity with matchedGeometryEffect
// Player side Group {
playerHandSection(width: handSectionWidth) if isDealing {
.debugBorder(showDebugBorders, color: .blue, label: "Player") // Vertical layout with flexible spacing between hands
VStack(spacing: 0) {
// Banker side if playerOnBottom {
bankerHandSection(width: handSectionWidth) bankerHandSection(width: handSectionWidth)
.debugBorder(showDebugBorders, color: .red, label: "Banker") .matchedGeometryEffect(id: "banker", in: animation)
// Flexible spacer expands on taller screens
Spacer(minLength: handsSpacing)
playerHandSection(width: handSectionWidth)
.matchedGeometryEffect(id: "player", in: animation)
} else {
playerHandSection(width: handSectionWidth)
.matchedGeometryEffect(id: "player", in: animation)
// Flexible spacer expands on taller screens
Spacer(minLength: handsSpacing)
bankerHandSection(width: handSectionWidth)
.matchedGeometryEffect(id: "banker", in: animation)
}
}
} else {
// Horizontal layout - Player left, Banker right
HStack(spacing: handsSpacing) {
playerHandSection(width: handSectionWidth)
.matchedGeometryEffect(id: "player", in: animation)
bankerHandSection(width: handSectionWidth)
.matchedGeometryEffect(id: "banker", in: animation)
}
}
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.top, Design.Spacing.medium) .padding(.top, Design.Spacing.medium)
@ -126,13 +199,14 @@ struct CardsDisplayArea: View {
.accessibilityHidden(true) .accessibilityHidden(true)
) )
.debugBorder(showDebugBorders, color: .mint, label: "HandsContainer") .debugBorder(showDebugBorders, color: .mint, label: "HandsContainer")
.animation(.spring(duration: 0.6, bounce: 0.2), value: isDealing)
.animation(.spring(duration: 0.5, bounce: 0.15), value: playerOnBottom)
} }
// MARK: - Private Views // MARK: - Private Views
private func playerHandSection(width: CGFloat) -> some View { private func playerHandSection(width: CGFloat) -> some View {
VStack(spacing: Design.Spacing.small) { VStack(spacing: Design.Spacing.small) {
// Label with value
HStack(spacing: Design.Spacing.small) { HStack(spacing: Design.Spacing.small) {
Text("PLAYER") Text("PLAYER")
.font(.system(size: labelFontSize, weight: .bold, design: .rounded)) .font(.system(size: labelFontSize, weight: .bold, design: .rounded))
@ -144,7 +218,6 @@ struct CardsDisplayArea: View {
} }
.frame(minHeight: labelRowMinHeight) .frame(minHeight: labelRowMinHeight)
// Cards
CompactHandView( CompactHandView(
cards: playerCards, cards: playerCards,
cardsFaceUp: playerCardsFaceUp, cardsFaceUp: playerCardsFaceUp,
@ -162,7 +235,6 @@ struct CardsDisplayArea: View {
private func bankerHandSection(width: CGFloat) -> some View { private func bankerHandSection(width: CGFloat) -> some View {
VStack(spacing: Design.Spacing.small) { VStack(spacing: Design.Spacing.small) {
// Label with value
HStack(spacing: Design.Spacing.small) { HStack(spacing: Design.Spacing.small) {
Text("BANKER") Text("BANKER")
.font(.system(size: labelFontSize, weight: .bold, design: .rounded)) .font(.system(size: labelFontSize, weight: .bold, design: .rounded))
@ -174,7 +246,6 @@ struct CardsDisplayArea: View {
} }
.frame(minHeight: labelRowMinHeight) .frame(minHeight: labelRowMinHeight)
// Cards
CompactHandView( CompactHandView(
cards: bankerCards, cards: bankerCards,
cardsFaceUp: bankerCardsFaceUp, cardsFaceUp: bankerCardsFaceUp,
@ -193,7 +264,7 @@ struct CardsDisplayArea: View {
// MARK: - Previews // MARK: - Previews
#Preview("Empty Hands") { #Preview("Horizontal - Betting Phase") {
ZStack { ZStack {
TableBackgroundView() TableBackgroundView()
CardsDisplayArea( CardsDisplayArea(
@ -207,12 +278,15 @@ struct CardsDisplayArea: View {
bankerIsWinner: false, bankerIsWinner: false,
isTie: false, isTie: false,
showAnimations: true, showAnimations: true,
dealingSpeed: 1.0 dealingSpeed: 1.0,
bettedOnPlayer: nil,
isDealing: false,
screenSize: CGSize(width: 400, height: 800)
) )
} }
} }
#Preview("Player Wins") { #Preview("Vertical - Dealing (Bet on Player)") {
ZStack { ZStack {
TableBackgroundView() TableBackgroundView()
CardsDisplayArea( CardsDisplayArea(
@ -232,12 +306,15 @@ struct CardsDisplayArea: View {
bankerIsWinner: false, bankerIsWinner: false,
isTie: false, isTie: false,
showAnimations: true, showAnimations: true,
dealingSpeed: 1.0 dealingSpeed: 1.0,
bettedOnPlayer: true,
isDealing: true,
screenSize: CGSize(width: 400, height: 800)
) )
} }
} }
#Preview("Banker Wins with 3 Cards") { #Preview("Vertical - Dealing (Bet on Banker)") {
ZStack { ZStack {
TableBackgroundView() TableBackgroundView()
CardsDisplayArea( CardsDisplayArea(
@ -254,13 +331,15 @@ struct CardsDisplayArea: View {
playerCardsFaceUp: [true, true, true], playerCardsFaceUp: [true, true, true],
bankerCardsFaceUp: [true, true, true], bankerCardsFaceUp: [true, true, true],
playerValue: 9, playerValue: 9,
bankerValue: 8, bankerValue: 9,
playerIsWinner: false, playerIsWinner: false,
bankerIsWinner: true, bankerIsWinner: true,
isTie: false, isTie: false,
showAnimations: true, showAnimations: true,
dealingSpeed: 1.0 dealingSpeed: 1.0,
bettedOnPlayer: false,
isDealing: true,
screenSize: CGSize(width: 400, height: 800)
) )
} }
} }

View File

@ -0,0 +1,195 @@
//
// HintViews.swift
// Baccarat
//
// Baccarat-specific hint views and supplementary components.
// The main BettingHintView is provided by CasinoKit.
//
import SwiftUI
import CasinoKit
// MARK: - Odds Info View
/// Shows the house edge information for educational purposes.
struct OddsInfoView: View {
@ScaledMetric(relativeTo: .caption) private var fontSize: CGFloat = Design.BaseFontSize.xSmall
var body: some View {
HStack(spacing: Design.Spacing.medium) {
oddsItem(label: "Banker", edge: "1.06%", color: .red)
oddsItem(label: "Player", edge: "1.24%", color: .blue)
oddsItem(label: "Tie", edge: "14.4%", color: .green)
}
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.xSmall)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.black.opacity(Design.Opacity.light))
)
}
private func oddsItem(label: String, edge: String, color: Color) -> some View {
VStack(spacing: Design.Spacing.xxSmall) {
Text(label)
.font(.system(size: fontSize, weight: .bold))
.foregroundStyle(color)
Text(edge)
.font(.system(size: fontSize, weight: .medium, design: .monospaced))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
}
}
}
// MARK: - Distribution Bar
/// A visual bar showing the distribution of Player/Banker/Tie results.
struct DistributionBarView: View {
let player: Int
let banker: Int
let tie: Int
@ScaledMetric(relativeTo: .caption) private var fontSize: CGFloat = Design.BaseFontSize.xSmall
private var total: Int { player + banker + tie }
private var playerPct: Double {
total > 0 ? Double(player) / Double(total) : 0.33
}
private var bankerPct: Double {
total > 0 ? Double(banker) / Double(total) : 0.33
}
private var tiePct: Double {
total > 0 ? Double(tie) / Double(total) : 0.34
}
var body: some View {
VStack(spacing: Design.Spacing.xSmall) {
// Bar
GeometryReader { geo in
HStack(spacing: 0) {
Rectangle()
.fill(Color.blue)
.frame(width: geo.size.width * playerPct)
Rectangle()
.fill(Color.green)
.frame(width: geo.size.width * tiePct)
Rectangle()
.fill(Color.red)
.frame(width: geo.size.width * bankerPct)
}
.clipShape(Capsule())
}
.frame(height: Design.Spacing.small)
// Labels
HStack {
Text("P: \(Int(playerPct * 100))%")
.foregroundStyle(.blue)
Spacer()
if tie > 0 {
Text("T: \(Int(tiePct * 100))%")
.foregroundStyle(.green)
Spacer()
}
Text("B: \(Int(bankerPct * 100))%")
.foregroundStyle(.red)
}
.font(.system(size: fontSize, weight: .medium, design: .rounded))
}
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.black.opacity(Design.Opacity.light))
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Result distribution"))
.accessibilityValue("Player \(Int(playerPct * 100))%, Banker \(Int(bankerPct * 100))%, Tie \(Int(tiePct * 100))%")
}
}
// MARK: - Streak Badge
/// A badge showing the current streak.
struct StreakBadgeView: View {
let type: GameResult
let count: Int
@ScaledMetric(relativeTo: .caption) private var fontSize: CGFloat = Design.BaseFontSize.body
private var color: Color {
switch type {
case .bankerWins: return .red
case .playerWins: return .blue
case .tie: return .green
}
}
private var letter: String {
switch type {
case .bankerWins: return "B"
case .playerWins: return "P"
case .tie: return "T"
}
}
var body: some View {
HStack(spacing: Design.Spacing.xxSmall) {
Image(systemName: "flame.fill")
.font(.system(size: fontSize))
.foregroundStyle(.orange)
Text("\(letter)×\(count)")
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundStyle(color)
}
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xxSmall)
.background(
Capsule()
.fill(Color.black.opacity(Design.Opacity.heavy))
.overlay(
Capsule()
.strokeBorder(color.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin)
)
)
.accessibilityLabel(String(localized: "Streak"))
.accessibilityValue("\(type == .bankerWins ? "Banker" : "Player") streak of \(count)")
}
}
// MARK: - Previews
#Preview("Distribution Bar") {
ZStack {
Color.Table.felt.ignoresSafeArea()
DistributionBarView(player: 12, banker: 15, tie: 3)
.frame(maxWidth: 300)
}
}
#Preview("Streak Badge") {
ZStack {
Color.Table.felt.ignoresSafeArea()
HStack(spacing: Design.Spacing.medium) {
StreakBadgeView(type: .bankerWins, count: 4)
StreakBadgeView(type: .playerWins, count: 3)
}
}
}
#Preview("Odds Info") {
ZStack {
Color.Table.felt.ignoresSafeArea()
OddsInfoView()
}
}

View File

@ -47,7 +47,7 @@ enum HandResult: Equatable {
var color: Color { var color: Color {
switch self { switch self {
case .blackjack: return .yellow case .blackjack: return Color(red: 0.85, green: 0.55, blue: 0.1) // Dark amber - good contrast with white text
case .win: return .green case .win: return .green
case .lose, .bust, .insuranceLose: return .red case .lose, .bust, .insuranceLose: return .red
case .push: return .blue case .push: return .blue

View File

@ -804,6 +804,7 @@
} }
}, },
"Add $%lld more to meet minimum" : { "Add $%lld more to meet minimum" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1455,6 +1456,7 @@
}, },
"Betting Hint" : { "Betting Hint" : {
"comment" : "A label describing the view that shows betting recommendations.", "comment" : "A label describing the view that shows betting recommendations.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -2218,6 +2220,7 @@
} }
}, },
"Deal" : { "Deal" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {

View File

@ -11,10 +11,6 @@ import CasinoKit
struct ActionButtonsView: View { struct ActionButtonsView: View {
@Bindable var state: GameState @Bindable var state: GameState
// Scaled metrics
@ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = Design.BaseFontSize.large
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.IconSize.large
// Scaled container height - base 60pt, scales with accessibility // Scaled container height - base 60pt, scales with accessibility
@ScaledMetric(relativeTo: .body) private var containerHeight: CGFloat = 60 @ScaledMetric(relativeTo: .body) private var containerHeight: CGFloat = 60
@ -25,8 +21,10 @@ struct ActionButtonsView: View {
switch state.currentPhase { switch state.currentPhase {
case .betting: case .betting:
bettingButtons bettingButtons
.transition(.opacity.combined(with: .scale(scale: 0.9)))
case .playerTurn: case .playerTurn:
playerTurnButtons playerTurnButtons
.transition(.opacity.combined(with: .scale(scale: 0.9)))
case .roundComplete: case .roundComplete:
// Empty - handled by result banner // Empty - handled by result banner
EmptyView() EmptyView()
@ -35,6 +33,7 @@ struct ActionButtonsView: View {
EmptyView() EmptyView()
} }
} }
.animation(.spring(duration: Design.Animation.springDuration), value: state.currentPhase)
} }
.frame(height: containerHeight) .frame(height: containerHeight)
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
@ -44,92 +43,97 @@ struct ActionButtonsView: View {
@ViewBuilder @ViewBuilder
private var bettingButtons: some View { private var bettingButtons: some View {
if state.currentBet > 0 { BettingActionsView(
VStack(spacing: Design.Spacing.small) { hasBet: state.currentBet > 0,
// Show hint if bet is below minimum canDeal: state.canDeal,
if state.isBetBelowMinimum { amountNeededForMinimum: state.isBetBelowMinimum ? state.amountNeededForMinimum : nil,
Text(String(localized: "Add $\(state.amountNeededForMinimum) more to meet minimum")) onClear: { state.clearBet() },
.font(.system(size: Design.BaseFontSize.small, weight: .medium)) onDeal: { Task { await state.deal() } }
.foregroundStyle(.orange) )
.transition(.opacity.combined(with: .scale(scale: 0.8)))
}
HStack(spacing: Design.Spacing.medium) {
ActionButton(
String(localized: "Clear"),
icon: "xmark.circle",
style: .destructive
) {
state.clearBet()
}
// Always show Deal button, but disable if below minimum
ActionButton(
String(localized: "Deal"),
icon: "play.fill",
style: .primary
) {
Task { await state.deal() }
}
.opacity(state.canDeal ? 1.0 : Design.Opacity.medium)
.disabled(!state.canDeal)
}
}
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: Design.Animation.standard), value: state.isBetBelowMinimum)
}
} }
// MARK: - Player Turn Buttons // MARK: - Player Turn Buttons
/// Available player actions based on current game state.
private var availableActions: [PlayerAction] {
PlayerAction.allCases.filter { action in
switch action {
case .hit: state.canHit
case .stand: state.canStand
case .double: state.canDouble
case .split: state.canSplit
case .surrender: state.canSurrender
}
}
}
/// Tracks available actions for animation purposes.
@State private var animatedActions: [PlayerAction] = []
@ViewBuilder @ViewBuilder
private var playerTurnButtons: some View { private var playerTurnButtons: some View {
// All player actions in a single row
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
if state.canHit { ForEach(animatedActions) { action in
ActionButton( ActionButton(
String(localized: "Hit"), action.title,
style: .custom(Color.Button.hit) style: .custom(action.color)
) { ) {
Task { await state.hit() } Task { await performAction(action) }
}
.transition(.scale.combined(with: .opacity))
}
}
.onAppear {
animatedActions = availableActions
}
.onChange(of: availableActions) { _, newActions in
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: 0.15)) {
animatedActions = newActions
}
} }
} }
if state.canStand { /// Performs the given player action.
ActionButton( private func performAction(_ action: PlayerAction) async {
String(localized: "Stand"), switch action {
style: .custom(Color.Button.stand) case .hit: await state.hit()
) { case .stand: await state.stand()
Task { await state.stand() } case .double: await state.doubleDown()
case .split: await state.split()
case .surrender: await state.surrender()
}
} }
} }
if state.canDouble { // MARK: - Player Action
ActionButton(
String(localized: "Double"), /// Represents a player action button during their turn.
style: .custom(Color.Button.doubleDown) private enum PlayerAction: String, CaseIterable, Identifiable {
) { case hit
Task { await state.doubleDown() } case stand
case double
case split
case surrender
var id: String { rawValue }
var title: String {
switch self {
case .hit: String(localized: "Hit")
case .stand: String(localized: "Stand")
case .double: String(localized: "Double")
case .split: String(localized: "Split")
case .surrender: String(localized: "Surrender")
} }
} }
if state.canSplit { var color: Color {
ActionButton( switch self {
String(localized: "Split"), case .hit: Color.Button.hit
style: .custom(Color.Button.split) case .stand: Color.Button.stand
) { case .double: Color.Button.doubleDown
Task { await state.split() } case .split: Color.Button.split
} case .surrender: Color.Button.surrender
}
if state.canSurrender {
ActionButton(
String(localized: "Surrender"),
style: .custom(Color.Button.surrender)
) {
Task { await state.surrender() }
}
}
} }
} }
} }

View File

@ -156,6 +156,7 @@ struct GameTableView: View {
currentBet: state.minBetForChipSelector, currentBet: state.minBetForChipSelector,
maxBet: state.settings.maxBet maxBet: state.settings.maxBet
) )
.transition(.opacity.combined(with: .move(edge: .bottom)))
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector") .debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
} }
@ -166,6 +167,7 @@ struct GameTableView: View {
.debugBorder(showDebugBorders, color: .blue, label: "ActionBtns") .debugBorder(showDebugBorders, color: .blue, label: "ActionBtns")
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.animation(.easeInOut(duration: Design.Animation.quick), value: state.currentPhase)
.zIndex(1) .zIndex(1)
.onChange(of: state.currentPhase) { oldPhase, newPhase in .onChange(of: state.currentPhase) { oldPhase, newPhase in
Design.debugLog("🔄 Phase changed: \(oldPhase)\(newPhase)") Design.debugLog("🔄 Phase changed: \(oldPhase)\(newPhase)")

View File

@ -215,7 +215,7 @@ struct BlackjackTableView: View {
// Betting hint based on count (only when card counting enabled) // Betting hint based on count (only when card counting enabled)
if let hint = state.bettingHint { if let hint = state.bettingHint {
BettingHintView(hint: hint, trueCount: state.engine.trueCount) BlackjackBettingHintView(hint: hint, trueCount: state.engine.trueCount)
.transition(.opacity) .transition(.opacity)
.padding(.vertical, 10) .padding(.vertical, 10)
.debugBorder(showDebugBorders, color: .purple, label: "BetHint") .debugBorder(showDebugBorders, color: .purple, label: "BetHint")

View File

@ -48,64 +48,28 @@ struct HintView: View {
} }
} }
// MARK: - Betting Hint View // MARK: - Blackjack Betting Hint View
/// Shows betting recommendations based on the current card count. /// Shows betting recommendations based on the current card count.
struct BettingHintView: View { /// Maps true count to hint style and uses CasinoKit's shared BettingHintView.
struct BlackjackBettingHintView: View {
let hint: String let hint: String
let trueCount: Double let trueCount: Double
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.Size.hintIconSize /// Determines the hint style based on true count.
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = Design.Size.hintFontSize private var style: BettingHintStyle {
@ScaledMetric(relativeTo: .body) private var paddingH: CGFloat = Design.Size.hintPaddingH
@ScaledMetric(relativeTo: .body) private var paddingV: CGFloat = Design.Size.hintPaddingV
private var hintColor: Color {
let tc = Int(trueCount.rounded()) let tc = Int(trueCount.rounded())
if tc >= 2 { if tc >= 2 {
return .green // Player advantage - bet more return .positive // Player advantage - bet more
} else if tc <= -1 { } else if tc <= -1 {
return .red // House advantage - bet less return .negative // House advantage - bet less
} else { } else {
return .yellow // Neutral return .neutral // Neutral
}
}
private var icon: String {
let tc = Int(trueCount.rounded())
if tc >= 2 {
return "arrow.up.circle.fill" // Increase bet
} else if tc <= -1 {
return "arrow.down.circle.fill" // Decrease bet
} else {
return "equal.circle.fill" // Neutral
} }
} }
var body: some View { var body: some View {
HStack(spacing: Design.Spacing.small) { BettingHintView(hint: hint, style: style)
Image(systemName: icon)
.font(.system(size: iconSize))
.foregroundStyle(hintColor)
Text(hint)
.font(.system(size: fontSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
}
.padding(.horizontal, paddingH)
.padding(.vertical, paddingV)
.background(
Capsule()
.fill(Color.black.opacity(Design.Opacity.light))
.overlay(
Capsule()
.strokeBorder(hintColor.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin)
)
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Betting Hint"))
.accessibilityValue(hint)
} }
} }
@ -128,21 +92,21 @@ struct BettingHintView: View {
#Preview("Betting Hint - Positive Count") { #Preview("Betting Hint - Positive Count") {
ZStack { ZStack {
Color.Table.felt.ignoresSafeArea() Color.Table.felt.ignoresSafeArea()
BettingHintView(hint: "Bet 4x minimum", trueCount: 2.5) BlackjackBettingHintView(hint: "Bet 4x minimum", trueCount: 2.5)
} }
} }
#Preview("Betting Hint - Negative Count") { #Preview("Betting Hint - Negative Count") {
ZStack { ZStack {
Color.Table.felt.ignoresSafeArea() Color.Table.felt.ignoresSafeArea()
BettingHintView(hint: "Bet minimum", trueCount: -1.5) BlackjackBettingHintView(hint: "Bet minimum", trueCount: -1.5)
} }
} }
#Preview("Betting Hint - Neutral") { #Preview("Betting Hint - Neutral") {
ZStack { ZStack {
Color.Table.felt.ignoresSafeArea() Color.Table.felt.ignoresSafeArea()
BettingHintView(hint: "Bet minimum (neutral)", trueCount: 0.0) BlackjackBettingHintView(hint: "Bet minimum (neutral)", trueCount: 0.0)
} }
} }

View File

@ -41,8 +41,13 @@
// - ValueBadge // - ValueBadge
// - ChipBadge // - ChipBadge
// MARK: - Hints
// - BettingHintView (shared betting recommendation view)
// - BettingHintStyle (positive, negative, neutral, streak, pattern, custom)
// MARK: - Buttons // MARK: - Buttons
// - ActionButton, ActionButtonStyle // - ActionButton, ActionButtonStyle
// - BettingActionsView (Clear/Deal button pair for betting phase)
// MARK: - Zones // MARK: - Zones
// - BettingZone // - BettingZone

View File

@ -334,6 +334,29 @@
} }
} }
}, },
"Add $%lld more to meet minimum" : {
"comment" : "Hint shown when bet is below table minimum. The argument is the dollar amount needed.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add $%lld more to meet minimum"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Agrega $%lld más para alcanzar el mínimo"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ajoutez %lld $ de plus pour atteindre le minimum"
}
}
}
},
"All game data is stored:" : { "All game data is stored:" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -451,6 +474,10 @@
} }
} }
}, },
"Betting Hint" : {
"comment" : "The accessibility label for the betting hint view.",
"isCommentAutoGenerated" : true
},
"Card face down" : { "Card face down" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -561,6 +588,33 @@
} }
} }
}, },
"Clear" : {
"comment" : "A button with an \"x\" icon and \"Clear\" label.",
"isCommentAutoGenerated" : true
},
"Clear bet" : {
"comment" : "Accessibility label for the Clear button that removes all bets.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Clear bet"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Borrar apuesta"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Effacer la mise"
}
}
}
},
"Clubs" : { "Clubs" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -629,6 +683,33 @@
} }
} }
}, },
"Deal" : {
"comment" : "A button that deals cards when pressed.",
"isCommentAutoGenerated" : true
},
"Deal cards" : {
"comment" : "Accessibility label for the Deal button that starts dealing cards.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Deal cards"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Repartir cartas"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Distribuer les cartes"
}
}
}
},
"Dealing Speed" : { "Dealing Speed" : {
"localizations" : { "localizations" : {
"en" : { "en" : {

View File

@ -229,6 +229,23 @@ public enum CasinoDesign {
public enum ChipStack { public enum ChipStack {
public static let maxChipsToShow: Int = 5 public static let maxChipsToShow: Int = 5
} }
// MARK: - Toast Configuration
public enum Toast {
/// Duration toasts stay visible before auto-dismiss.
public static let duration: Duration = .seconds(3)
}
// MARK: - Hint Sizes
public enum HintSize {
public static let iconSize: CGFloat = 16
public static let fontSize: CGFloat = 13
public static let paddingH: CGFloat = 12
public static let paddingV: CGFloat = 8
public static let minWidth: CGFloat = 180
}
} }
// MARK: - CasinoKit Colors // MARK: - CasinoKit Colors

View File

@ -0,0 +1,171 @@
//
// BettingHintView.swift
// CasinoKit
//
// A shared betting hint view for displaying recommendations across casino games.
//
import SwiftUI
/// Style of the betting hint (affects color and icon).
public enum BettingHintStyle {
/// Positive/favorable hint (green)
case positive
/// Negative/warning hint (red)
case negative
/// Neutral/informational hint (yellow)
case neutral
/// Streak-based hint (orange/flame)
case streak
/// Choppy/alternating pattern (blue)
case pattern
/// Custom color
case custom(Color, String)
var color: Color {
switch self {
case .positive: return .green
case .negative: return .red
case .neutral: return .yellow
case .streak: return .orange
case .pattern: return .blue
case .custom(let color, _): return color
}
}
var icon: String {
switch self {
case .positive: return "arrow.up.circle.fill"
case .negative: return "arrow.down.circle.fill"
case .neutral: return "lightbulb.fill"
case .streak: return "flame.fill"
case .pattern: return "arrow.left.arrow.right"
case .custom(_, let icon): return icon
}
}
}
/// A shared betting hint view for casino games.
/// Shows betting recommendations with consistent styling.
public struct BettingHintView: View {
/// The main hint text.
public let hint: String
/// Optional secondary info (e.g., trend percentage).
public let secondaryInfo: String?
/// The style of the hint (determines color and icon).
public let style: BettingHintStyle
// MARK: - Scaled Metrics
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = CasinoDesign.HintSize.iconSize
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = CasinoDesign.HintSize.fontSize
@ScaledMetric(relativeTo: .body) private var paddingH: CGFloat = CasinoDesign.HintSize.paddingH
@ScaledMetric(relativeTo: .body) private var paddingV: CGFloat = CasinoDesign.HintSize.paddingV
/// Creates a betting hint view.
/// - Parameters:
/// - hint: The main hint text.
/// - secondaryInfo: Optional secondary info text.
/// - style: The visual style of the hint.
public init(hint: String, secondaryInfo: String? = nil, style: BettingHintStyle = .neutral) {
self.hint = hint
self.secondaryInfo = secondaryInfo
self.style = style
}
public var body: some View {
HStack(spacing: CasinoDesign.Spacing.small) {
Image(systemName: style.icon)
.font(.system(size: iconSize))
.foregroundStyle(style.color)
VStack(alignment: .leading, spacing: CasinoDesign.Spacing.xxSmall) {
Text(hint)
.font(.system(size: fontSize, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(CasinoDesign.MinScaleFactor.comfortable)
if let secondary = secondaryInfo {
Text(secondary)
.font(.system(size: fontSize - 2, weight: .regular))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
.lineLimit(1)
}
}
}
.padding(.horizontal, paddingH)
.padding(.vertical, paddingV)
.frame(minWidth: CasinoDesign.HintSize.minWidth, alignment: .leading)
.background(
Capsule()
.fill(Color.black.opacity(CasinoDesign.Opacity.heavy))
.overlay(
Capsule()
.strokeBorder(style.color.opacity(CasinoDesign.Opacity.medium), lineWidth: CasinoDesign.LineWidth.thin)
)
)
.shadow(color: .black.opacity(CasinoDesign.Opacity.medium), radius: CasinoDesign.Shadow.radiusMedium)
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Betting Hint", bundle: .module))
.accessibilityValue(hint)
}
}
// MARK: - Previews
#Preview("Neutral Hint") {
ZStack {
Color.CasinoTable.felt.ignoresSafeArea()
BettingHintView(
hint: "Banker has the lowest house edge (1.06%)",
style: .neutral
)
}
}
#Preview("Positive Hint") {
ZStack {
Color.CasinoTable.felt.ignoresSafeArea()
BettingHintView(
hint: "Bet 4x minimum",
secondaryInfo: "True count: +3",
style: .positive
)
}
}
#Preview("Negative Hint") {
ZStack {
Color.CasinoTable.felt.ignoresSafeArea()
BettingHintView(
hint: "Bet minimum",
secondaryInfo: "True count: -2",
style: .negative
)
}
}
#Preview("Streak Hint") {
ZStack {
Color.CasinoTable.felt.ignoresSafeArea()
BettingHintView(
hint: "Banker streak: 5 in a row",
secondaryInfo: "B: 58% | P: 38%",
style: .streak
)
}
}
#Preview("Pattern Hint") {
ZStack {
Color.CasinoTable.felt.ignoresSafeArea()
BettingHintView(
hint: "Choppy shoe - results alternating",
style: .pattern
)
}
}

View File

@ -0,0 +1,209 @@
//
// BettingActionsView.swift
// CasinoKit
//
// Reusable Clear/Deal button pair for casino games betting phase.
//
import SwiftUI
/// A reusable view that displays Clear and Deal buttons for casino game betting phases.
///
/// This view follows the "always visible, disabled when unavailable" pattern for better UX:
/// - Buttons are always shown to maintain layout stability
/// - Clear is disabled when no bet is placed
/// - Deal is disabled when the bet doesn't meet requirements
/// - Optional minimum bet hint displays when bet is below minimum
public struct BettingActionsView: View {
// MARK: - Properties
/// Whether the player has any bet placed.
public let hasBet: Bool
/// Whether the deal action is currently available.
public let canDeal: Bool
/// Amount needed to reach minimum bet (shown as hint when > 0).
public let amountNeededForMinimum: Int?
/// Action when Clear is tapped.
public let onClear: () -> Void
/// Action when Deal is tapped.
public let onDeal: () -> Void
// MARK: - Environment
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
/// Whether the current text size is an accessibility size (very large).
private var isAccessibilitySize: Bool {
dynamicTypeSize.isAccessibilitySize
}
// MARK: - Layout Constants
private let buttonFontSize: CGFloat = CasinoDesign.BaseFontSize.xLarge
private let iconSize: CGFloat = CasinoDesign.BaseFontSize.xxLarge + CasinoDesign.Spacing.xSmall
private let hintFontSize: CGFloat = CasinoDesign.BaseFontSize.small
// MARK: - Initialization
/// Creates a betting actions view.
/// - Parameters:
/// - hasBet: Whether there is any bet placed.
/// - canDeal: Whether deal is currently available.
/// - amountNeededForMinimum: Optional amount needed to reach minimum (shown as hint).
/// - onClear: Action when Clear is tapped.
/// - onDeal: Action when Deal is tapped.
public init(
hasBet: Bool,
canDeal: Bool,
amountNeededForMinimum: Int? = nil,
onClear: @escaping () -> Void,
onDeal: @escaping () -> Void
) {
self.hasBet = hasBet
self.canDeal = canDeal
self.amountNeededForMinimum = amountNeededForMinimum
self.onClear = onClear
self.onDeal = onDeal
}
// MARK: - Body
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.small) {
// Show hint if bet is below minimum
if let amount = amountNeededForMinimum, amount > 0 {
Text(String(localized: "Add $\(amount) more to meet minimum", bundle: .module))
.font(.system(size: hintFontSize, weight: .medium))
.foregroundStyle(.orange)
.transition(.opacity.combined(with: .scale(scale: 0.8)))
}
HStack(spacing: CasinoDesign.Spacing.medium) {
clearButton
dealButton
}
}
.animation(.easeInOut(duration: CasinoDesign.Animation.standard), value: amountNeededForMinimum)
}
// MARK: - Private Views
@ViewBuilder
private var clearButton: some View {
let isEnabled = hasBet
if isAccessibilitySize {
Button("Clear", systemImage: "xmark.circle", action: onClear)
.labelStyle(.iconOnly)
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(.white)
.padding(CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall)
.background(
Circle()
.fill(Color.CasinoButton.destructive)
)
.opacity(isEnabled ? 1.0 : CasinoDesign.Opacity.disabled)
.disabled(!isEnabled)
.accessibilityLabel(String(localized: "Clear bet", bundle: .module))
} else {
Button("Clear", systemImage: "xmark.circle", action: onClear)
.labelStyle(.titleAndIcon)
.font(.system(size: buttonFontSize, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, CasinoDesign.Spacing.xxLarge)
.padding(.vertical, CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall)
.background(
Capsule()
.fill(Color.CasinoButton.destructive)
)
.opacity(isEnabled ? 1.0 : CasinoDesign.Opacity.disabled)
.disabled(!isEnabled)
.accessibilityLabel(String(localized: "Clear bet", bundle: .module))
}
}
@ViewBuilder
private var dealButton: some View {
let buttonBackground = LinearGradient(
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
startPoint: .top,
endPoint: .bottom
)
if isAccessibilitySize {
Button("Deal", systemImage: "play.fill", action: onDeal)
.labelStyle(.iconOnly)
.font(.system(size: iconSize, weight: .bold))
.foregroundStyle(.black)
.padding(CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall)
.background(
Circle()
.fill(buttonBackground)
)
.shadow(color: .yellow.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusMedium)
.opacity(canDeal ? 1.0 : CasinoDesign.Opacity.disabled)
.disabled(!canDeal)
.accessibilityLabel(String(localized: "Deal cards", bundle: .module))
} else {
Button("Deal", systemImage: "play.fill", action: onDeal)
.labelStyle(.titleAndIcon)
.font(.system(size: buttonFontSize, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, CasinoDesign.Spacing.xxLarge)
.padding(.vertical, CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall)
.background(
Capsule()
.fill(buttonBackground)
)
.shadow(color: .yellow.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusMedium)
.opacity(canDeal ? 1.0 : CasinoDesign.Opacity.disabled)
.disabled(!canDeal)
.accessibilityLabel(String(localized: "Deal cards", bundle: .module))
}
}
}
// MARK: - Previews
#Preview("No Bet Placed") {
ZStack {
Color.CasinoTable.felt.ignoresSafeArea()
BettingActionsView(
hasBet: false,
canDeal: false,
onClear: {},
onDeal: {}
)
}
}
#Preview("Bet Below Minimum") {
ZStack {
Color.CasinoTable.felt.ignoresSafeArea()
BettingActionsView(
hasBet: true,
canDeal: false,
amountNeededForMinimum: 25,
onClear: {},
onDeal: {}
)
}
}
#Preview("Ready to Deal") {
ZStack {
Color.CasinoTable.felt.ignoresSafeArea()
BettingActionsView(
hasBet: true,
canDeal: true,
onClear: {},
onDeal: {}
)
}
}