Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-16 21:26:24 -06:00
parent 41eb7cd66c
commit e1d1f29793
9 changed files with 1353 additions and 442 deletions

View File

@ -431,8 +431,8 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -463,8 +463,8 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@ -164,12 +164,72 @@ struct BaccaratEngine {
}
}
// MARK: - Side Bet Checks
/// Whether the Player hand has a pair (first two cards same rank).
var playerHasPair: Bool {
guard playerHand.cardCount >= 2,
let first = playerHand.cards.first,
let second = playerHand.cards.dropFirst().first else {
return false
}
return first.rank == second.rank
}
/// Whether the Banker hand has a pair (first two cards same rank).
var bankerHasPair: Bool {
guard bankerHand.cardCount >= 2,
let first = bankerHand.cards.first,
let second = bankerHand.cards.dropFirst().first else {
return false
}
return first.rank == second.rank
}
/// The margin of victory for Player (positive) or Banker (negative).
/// Zero means tie.
var victoryMargin: Int {
playerHand.value - bankerHand.value
}
/// Whether the winning hand had a natural (8 or 9).
var winnerHadNatural: Bool {
let result = determineResult()
switch result {
case .playerWins: return playerHand.isNatural
case .bankerWins: return bankerHand.isNatural
case .tie: return false
}
}
// MARK: - Payout Calculations
/// Calculates the payout for a bet given the result.
/// - Parameters:
/// - bet: The bet that was placed.
/// - result: The result of the round.
/// - Returns: The net winnings (positive), net loss (negative), or 0 for push.
func calculatePayout(bet: Bet, result: GameResult) -> Int {
switch bet.type {
case .player, .banker, .tie:
return calculateMainBetPayout(bet: bet, result: result)
case .playerPair:
return playerHasPair ? Int(Double(bet.amount) * bet.type.payoutMultiplier) : -bet.amount
case .bankerPair:
return bankerHasPair ? Int(Double(bet.amount) * bet.type.payoutMultiplier) : -bet.amount
case .dragonBonusPlayer:
return calculateDragonBonusPayout(bet: bet, forPlayer: true, result: result)
case .dragonBonusBanker:
return calculateDragonBonusPayout(bet: bet, forPlayer: false, result: result)
}
}
/// Calculates payout for main bets (Player, Banker, Tie).
private func calculateMainBetPayout(bet: Bet, result: GameResult) -> Int {
if result.isPush(for: bet.type) {
// Push - bet is returned
return 0
@ -184,6 +244,29 @@ struct BaccaratEngine {
}
}
/// Calculates Dragon Bonus payout.
private func calculateDragonBonusPayout(bet: Bet, forPlayer: Bool, result: GameResult) -> Int {
// Determine if the side we bet on won
let ourSideWon = forPlayer ? (result == .playerWins) : (result == .bankerWins)
if !ourSideWon {
// Dragon Bonus loses if our side didn't win (including ties)
return -bet.amount
}
// Calculate margin
let margin = abs(victoryMargin)
let isNatural = forPlayer ? playerHand.isNatural : bankerHand.isNatural
// Get the multiplier
if let multiplier = DragonBonusPayout.multiplier(for: margin, isNatural: isNatural) {
return bet.amount * multiplier
} else {
// Win by less than 4 - loses
return -bet.amount
}
}
/// Plays a complete round automatically and returns the result.
/// Used for simulation/testing purposes.
mutating func playRound() -> GameResult {
@ -194,4 +277,3 @@ struct BaccaratEngine {
return determineResult()
}
}

View File

@ -38,6 +38,11 @@ final class GameState {
var lastResult: GameResult?
var lastWinnings: Int = 0
// MARK: - Side Bet Results
var playerHadPair: Bool = false
var bankerHadPair: Bool = false
var dragonBonusPayouts: [BetType: Int] = [:]
// MARK: - Card Display State (for animations)
var visiblePlayerCards: [Card] = []
var visibleBankerCards: [Card] = []
@ -121,11 +126,21 @@ final class GameState {
currentBets.first(where: { $0.type == .tie })
}
/// Returns bets for a specific type.
func bet(for type: BetType) -> Bet? {
currentBets.first(where: { $0.type == type })
}
/// Whether the player has placed a main bet (required to deal).
var hasMainBet: Bool {
mainBet != nil
}
/// Whether the player has any side bets.
var hasSideBets: Bool {
currentBets.contains(where: { $0.type.isSideBet })
}
/// Minimum bet for the table.
var minBet: Int {
settings.minBet
@ -156,7 +171,7 @@ final class GameState {
// MARK: - Betting Actions
/// Places a bet of the specified amount on the given bet type.
/// Player and Banker are mutually exclusive. Tie can be added as a side bet.
/// Player and Banker are mutually exclusive. Side bets can be added independently.
/// Enforces min/max table limits.
func placeBet(type: BetType, amount: Int) {
guard canPlaceBet, balance >= amount else { return }
@ -193,7 +208,7 @@ final class GameState {
currentBets = []
}
/// Removes the last bet placed.
/// Undoes the last bet placed.
func undoLastBet() {
guard canPlaceBet, let lastBet = currentBets.last else { return }
balance += lastBet.amount
@ -209,13 +224,16 @@ final class GameState {
isAnimating = true
engine.prepareNewRound()
// Clear visible cards
// Clear visible cards and side bet results
visiblePlayerCards = []
visibleBankerCards = []
playerCardsFaceUp = []
bankerCardsFaceUp = []
lastResult = nil
showResultBanner = false
playerHadPair = false
bankerHadPair = false
dragonBonusPayouts = [:]
// Deal initial cards
currentPhase = .dealingInitial
@ -309,12 +327,21 @@ final class GameState {
let result = engine.determineResult()
lastResult = result
// Record pair results for display
playerHadPair = engine.playerHasPair
bankerHadPair = engine.bankerHasPair
// Calculate and apply payouts
var totalWinnings = 0
for bet in currentBets {
let payout = engine.calculatePayout(bet: bet, result: result)
totalWinnings += payout
// Track dragon bonus payouts for display
if bet.type == .dragonBonusPlayer || bet.type == .dragonBonusBanker {
dragonBonusPayouts[bet.type] = payout
}
// Return original bet if not a loss
if payout >= 0 {
balance += bet.amount
@ -356,6 +383,9 @@ final class GameState {
bankerCardsFaceUp = []
lastResult = nil
lastWinnings = 0
playerHadPair = false
bankerHadPair = false
dragonBonusPayouts = [:]
currentPhase = .betting
}
@ -383,6 +413,9 @@ final class GameState {
roundHistory = []
isAnimating = false
showResultBanner = false
playerHadPair = false
bankerHadPair = false
dragonBonusPayouts = [:]
}
/// Applies new settings (call after settings change).
@ -390,4 +423,3 @@ final class GameState {
resetGame()
}
}

View File

@ -10,19 +10,38 @@ import SwiftUI
/// The types of bets available in baccarat.
enum BetType: String, CaseIterable, Identifiable {
// MARK: - Main Bets
case player = "Player"
case banker = "Banker"
case tie = "Tie"
// MARK: - Side Bets
case playerPair = "Player Pair"
case bankerPair = "Banker Pair"
case dragonBonusPlayer = "Dragon Bonus Player"
case dragonBonusBanker = "Dragon Bonus Banker"
var id: String { rawValue }
/// The payout multiplier for winning this bet type.
/// Player pays 1:1, Banker pays 0.95:1 (5% commission), Tie pays 8:1.
/// Whether this is a main bet (Player/Banker) that is required to play.
var isMainBet: Bool {
self == .player || self == .banker
}
/// Whether this is a side bet.
var isSideBet: Bool {
!isMainBet && self != .tie
}
/// The base payout multiplier for winning this bet type.
/// Note: Dragon Bonus has variable payouts based on margin - this is the natural win payout.
var payoutMultiplier: Double {
switch self {
case .player: return 1.0
case .banker: return 0.95 // 5% commission
case .tie: return 8.0
case .playerPair, .bankerPair: return 11.0 // 11:1
case .dragonBonusPlayer, .dragonBonusBanker: return 1.0 // Base for natural
}
}
@ -32,6 +51,34 @@ enum BetType: String, CaseIterable, Identifiable {
case .player: return "Player (1:1)"
case .banker: return "Banker (0.95:1)"
case .tie: return "Tie (8:1)"
case .playerPair: return "P Pair (11:1)"
case .bankerPair: return "B Pair (11:1)"
case .dragonBonusPlayer: return "Dragon P"
case .dragonBonusBanker: return "Dragon B"
}
}
/// Short display name for betting zones.
var shortName: String {
switch self {
case .player: return "PLAYER"
case .banker: return "BANKER"
case .tie: return "TIE"
case .playerPair: return "P PAIR"
case .bankerPair: return "B PAIR"
case .dragonBonusPlayer: return "P BONUS"
case .dragonBonusBanker: return "B BONUS"
}
}
/// The payout description shown in betting zones.
var payoutDescription: String {
switch self {
case .player: return "PAYS 1 TO 1"
case .banker: return "PAYS 0.95 TO 1"
case .tie: return "PAYS 8 TO 1"
case .playerPair, .bankerPair: return "PAYS 11 TO 1"
case .dragonBonusPlayer, .dragonBonusBanker: return "UP TO 30 TO 1"
}
}
@ -41,8 +88,32 @@ enum BetType: String, CaseIterable, Identifiable {
case .player: return .blue
case .banker: return .red
case .tie: return .green
case .playerPair: return .blue.opacity(0.7)
case .bankerPair: return .red.opacity(0.7)
case .dragonBonusPlayer: return .purple
case .dragonBonusBanker: return .orange
}
}
/// All main betting options (required to play).
static var mainBets: [BetType] {
[.player, .banker]
}
/// All side bets.
static var sideBets: [BetType] {
[.tie, .playerPair, .bankerPair, .dragonBonusPlayer, .dragonBonusBanker]
}
/// Pair bets.
static var pairBets: [BetType] {
[.playerPair, .bankerPair]
}
/// Dragon bonus bets.
static var dragonBets: [BetType] {
[.dragonBonusPlayer, .dragonBonusBanker]
}
}
/// Represents a bet placed by the user.
@ -56,3 +127,39 @@ struct Bet: Identifiable, Equatable {
}
}
/// Dragon Bonus payout table based on margin of victory.
enum DragonBonusPayout {
/// Returns the payout multiplier for Dragon Bonus based on the margin of victory.
/// Returns nil if the bet loses.
static func multiplier(for margin: Int, isNatural: Bool) -> Int? {
// Natural win (8 or 9)
if isNatural && margin > 0 {
return 1
}
// Non-natural wins by margin
switch margin {
case 9: return 30
case 8: return 10
case 7: return 6
case 6: return 4
case 5: return 2
case 4: return 1
default: return nil // Loses on margin < 4, tie, or loss
}
}
/// All possible payouts for display in rules.
static var payoutTable: [(margin: String, payout: String)] {
[
("Natural Win (8 or 9)", "1 to 1"),
("Win by 9", "30 to 1"),
("Win by 8", "10 to 1"),
("Win by 7", "6 to 1"),
("Win by 6", "4 to 1"),
("Win by 5", "2 to 1"),
("Win by 4", "1 to 1"),
("Win by 0-3 or Lose", "Lose")
]
}
}

View File

@ -36,6 +36,10 @@
}
}
},
"•" : {
"comment" : "A bullet point used to list items in a rule section.",
"isCommentAutoGenerated" : true
},
"$" : {
"comment" : "The currency symbol \"$\".",
"localizations" : {
@ -110,6 +114,14 @@
}
}
},
"8 TO 1" : {
"comment" : "The payout ratio for a tie bet.",
"isCommentAutoGenerated" : true
},
"11:1" : {
"comment" : "The payout ratio for a pair bonus bet.",
"isCommentAutoGenerated" : true
},
"B" : {
"comment" : "The letter \"B\" displayed in the center of the playing card's back.",
"extractionState" : "stale",
@ -146,6 +158,13 @@
}
}
},
"BACCARAT" : {
},
"BACK TO GAME" : {
"comment" : "A button label that takes the user back to the main game screen.",
"isCommentAutoGenerated" : true
},
"Balance" : {
"comment" : "A label describing the user's current balance.",
"isCommentAutoGenerated" : true
@ -221,14 +240,18 @@
}
}
},
"Banker bet, pays 0.95 to 1" : {
"comment" : "Accessibility label for the banker betting zone.",
"Banker %@:" : {
"comment" : "A label displaying the total for the banker and an action to take based on that total. The first argument is the banker's total. The second argument is a string describing the action to take based on that total",
"isCommentAutoGenerated" : true
},
"Banker hand" : {
"comment" : "A label displayed above the banker's hand.",
"isCommentAutoGenerated" : true
},
"Banker Rules" : {
"comment" : "A section header for the banker's rules in the third card rules content.",
"isCommentAutoGenerated" : true
},
"BANKER WINS" : {
"comment" : "Result banner text when banker wins.",
"extractionState" : "stale",
@ -271,8 +294,8 @@
}
}
},
"Betting disabled" : {
"comment" : "Accessibility hint text for the TIE betting zone when it is disabled.",
"BONUS" : {
"comment" : "The text displayed in the center of the bonus zone.",
"isCommentAutoGenerated" : true
},
"Cancel" : {
@ -310,6 +333,10 @@
}
}
},
"Card Values" : {
"comment" : "A heading that explains the values of playing cards.",
"isCommentAutoGenerated" : true
},
"Cards face down" : {
"comment" : "Voiceover description of the player's hand when no cards are visible.",
"isCommentAutoGenerated" : true
@ -353,10 +380,6 @@
}
}
},
"currentBetFormat" : {
"comment" : "Format string for the amount of the current bet.",
"isCommentAutoGenerated" : true
},
"Deal" : {
"comment" : "The label of a button that deals cards in a game.",
"localizations" : {
@ -497,9 +520,11 @@
}
}
},
"Double tap to place bet" : {
"comment" : "Accessibility hint text for the TIE betting zone.",
"isCommentAutoGenerated" : true
"Dragon Bonus" : {
},
"Examples" : {
},
"Game history" : {
"comment" : "The accessibility label for the road map view, describing it as a display of game results.",
@ -548,6 +573,10 @@
"comment" : "A description of the player's hand, including the visible cards and their total value.",
"isCommentAutoGenerated" : true
},
"Help" : {
"comment" : "The label of a button that shows help information.",
"isCommentAutoGenerated" : true
},
"HISTORY" : {
"comment" : "A label displayed above the road map view, indicating that it shows a history of past game results.",
"localizations" : {
@ -587,10 +616,22 @@
"comment" : "Format string used to create a summary of the user's game history, including the total number of rounds played, as well as the number of rounds won by the player, the banker, and as ties.",
"isCommentAutoGenerated" : true
},
"How to Play" : {
"comment" : "Title for the first rule page in the help view.",
"isCommentAutoGenerated" : true
},
"Important" : {
"comment" : "A heading for important information related to a section of a view.",
"isCommentAutoGenerated" : true
},
"lostAmountFormat" : {
"comment" : "Format string used to describe the amount lost in a round.",
"isCommentAutoGenerated" : true
},
"M" : {
"comment" : "The letter \"M\" displayed on a mini chip indicator to represent the maximum bet.",
"isCommentAutoGenerated" : true
},
"MAX" : {
"comment" : "A label displayed as a badge on top-right of a chip to indicate it's the maximum bet.",
"extractionState" : "stale",
@ -627,12 +668,8 @@
}
}
},
"maximum bet" : {
"comment" : "Text describing the maximum bet option.",
"isCommentAutoGenerated" : true
},
"Maximum bet reached" : {
"comment" : "Text to be read out loud when the TIE betting zone is at its maximum bet value.",
"Natural Win" : {
"comment" : "A section header for information about winning with a natural hand.",
"isCommentAutoGenerated" : true
},
"New Round" : {
@ -678,8 +715,24 @@
"comment" : "Summary text for a road map view when there is no history.",
"isCommentAutoGenerated" : true
},
"Pair Bonus" : {
"comment" : "Title of the page explaining the pair bonus in Baccarat.",
"isCommentAutoGenerated" : true
},
"Pair Pays" : {
"comment" : "The text that appears above the payout value for a pair bonus bet.",
"isCommentAutoGenerated" : true
},
"Payout" : {
},
"Payout Table" : {
"comment" : "The title of a table that lists possible payouts for a dragon bonus bet.",
"isCommentAutoGenerated" : true
},
"PAYS 0.95 TO 1" : {
"comment" : "A description of the payout for betting on the banker in a mini baccarat table.",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@ -715,6 +768,7 @@
},
"PAYS 1 TO 1" : {
"comment" : "A description of the payout ratio for the player bet.",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@ -750,6 +804,7 @@
},
"PAYS 8 TO 1" : {
"comment" : "A description of the payout for betting on a tie in mini baccarat.",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@ -853,13 +908,12 @@
}
}
},
"Player bet, pays 1 to 1" : {
"comment" : "Accessibility label for the player betting zone.",
"isCommentAutoGenerated" : true
},
"Player hand" : {
"comment" : "An accessibility label for the player's hand in the cards display area.",
"isCommentAutoGenerated" : true
},
"Player Rules" : {
},
"PLAYER WINS" : {
"comment" : "Result banner text when player wins.",
@ -1008,10 +1062,6 @@
}
}
},
"selected" : {
"comment" : "Text to be read out loud when the Banker betting zone is selected.",
"isCommentAutoGenerated" : true
},
"Settings" : {
"comment" : "The label of a button that navigates to the settings screen.",
"localizations" : {
@ -1088,6 +1138,9 @@
}
}
}
},
"Third Card Rules" : {
},
"TIE" : {
"comment" : "The text displayed in the TIE betting zone.",
@ -1124,10 +1177,6 @@
}
}
},
"Tie bet, pays 8 to 1" : {
"comment" : "Accessibility label for the TIE betting zone at the top of the table.",
"isCommentAutoGenerated" : true
},
"TIE GAME" : {
"comment" : "Result banner text when the game is a tie.",
"extractionState" : "stale",
@ -1170,6 +1219,10 @@
}
}
},
"Tips" : {
"comment" : "A section header for tips related to pair bonuses.",
"isCommentAutoGenerated" : true
},
"WIN" : {
"comment" : "The text that appears as a badge when a player wins a hand in baccarat.",
"localizations" : {

View File

@ -14,6 +14,7 @@ struct GameTableView: View {
@State private var gameState: GameState?
@State private var selectedChip: ChipDenomination = .hundred
@State private var showSettings = false
@State private var showRules = false
private var state: GameState {
gameState ?? GameState(settings: settings)
@ -31,6 +32,29 @@ struct GameTableView: View {
state.lastResult == .tie
}
/// Builds descriptions for side bet wins to display in the result banner.
private func buildSideBetDescriptions(state: GameState) -> [String] {
var descriptions: [String] = []
// Check pair bets
if state.playerHadPair && state.bet(for: .playerPair) != nil {
descriptions.append("Player Pair Win!")
}
if state.bankerHadPair && state.bet(for: .bankerPair) != nil {
descriptions.append("Banker Pair Win!")
}
// Check dragon bonus payouts
if let payout = state.dragonBonusPayouts[.dragonBonusPlayer], payout > 0 {
descriptions.append("Dragon Player +\(payout)")
}
if let payout = state.dragonBonusPayouts[.dragonBonusBanker], payout > 0 {
descriptions.append("Dragon Banker +\(payout)")
}
return descriptions
}
var body: some View {
ZStack {
// Table background
@ -44,7 +68,8 @@ struct GameTableView: View {
cardsRemaining: state.engine.shoe.cardsRemaining,
showCardsRemaining: settings.showCardsRemaining,
onReset: { state.resetGame() },
onSettings: { showSettings = true }
onSettings: { showSettings = true },
onHelp: { showRules = true }
)
Spacer(minLength: Design.Spacing.xSmall)
@ -109,7 +134,10 @@ struct GameTableView: View {
if state.showResultBanner, let result = state.lastResult {
ResultBannerView(
result: result,
winnings: state.lastWinnings
winnings: state.lastWinnings,
playerHadPair: state.playerHadPair,
bankerHadPair: state.bankerHadPair,
sideBetWinnings: buildSideBetDescriptions(state: state)
)
.transition(.opacity)
@ -139,6 +167,9 @@ struct GameTableView: View {
gameState?.applySettings()
}
}
.fullScreenCover(isPresented: $showRules) {
RulesHelpView()
}
}
}
@ -530,6 +561,7 @@ struct TopBarView: View {
let showCardsRemaining: Bool
let onReset: () -> Void
let onSettings: () -> Void
let onHelp: () -> Void
// MARK: - Environment
@ -594,6 +626,17 @@ struct TopBarView: View {
Spacer()
}
// Help/Rules button
Button("Help", systemImage: "info.circle.fill", action: onHelp)
.labelStyle(.iconOnly)
.font(.system(size: buttonFontSize))
.foregroundStyle(.white.opacity(0.6))
.padding(Design.Spacing.small)
.background(
Circle()
.fill(Color.black.opacity(Design.Opacity.overlay))
)
// Settings button (icon only)
Button("Settings", systemImage: "gearshape.fill", action: onSettings)
.labelStyle(.iconOnly)

View File

@ -2,7 +2,7 @@
// MiniBaccaratTableView.swift
// Baccarat
//
// A realistic mini baccarat table layout for single player.
// A realistic mini baccarat table layout for single player with side bets.
//
import SwiftUI
@ -14,21 +14,9 @@ struct MiniBaccaratTableView: View {
let selectedChip: ChipDenomination
// MARK: - Fixed Font Sizes
// Fixed because the table area has strict layout constraints
private let tableLimitsFontSize: CGFloat = Design.BaseFontSize.small
// MARK: - Layout Constants
private let tieZoneHeight: CGFloat = 55
private let mainZoneHeight: CGFloat = 60
private let tieHorizontalPadding: CGFloat = 50
private let bankerHorizontalPadding: CGFloat = 30
private let playerHorizontalPadding: CGFloat = 20
private let zoneTopPadding = Design.Spacing.medium
private let zoneBottomPadding = Design.Spacing.medium
private let minSpacerLength = Design.Spacing.small
// MARK: - Computed Properties
private func betAmount(for type: BetType) -> Int {
@ -73,6 +61,7 @@ struct MiniBaccaratTableView: View {
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
// Main table
ZStack {
// Table felt background with arc shape
TableFeltShape()
@ -96,47 +85,61 @@ struct MiniBaccaratTableView: View {
)
// Betting zones layout
VStack(spacing: 0) {
// TIE zone at top
TieBettingZone(
betAmount: betAmount(for: .tie),
isEnabled: canAddBet(for: .tie),
isAtMax: isAtMax(for: .tie)
) {
gameState.placeBet(type: .tie, amount: selectedChip.rawValue)
}
.frame(height: tieZoneHeight)
.padding(.horizontal, tieHorizontalPadding)
.padding(.top, zoneTopPadding)
VStack(spacing: Design.Spacing.xSmall) {
// Top row: B PAIR | TIE | P PAIR
TopBettingRow(
bankerPairAmount: betAmount(for: .bankerPair),
tieAmount: betAmount(for: .tie),
playerPairAmount: betAmount(for: .playerPair),
canBetBankerPair: canAddBet(for: .bankerPair),
canBetTie: canAddBet(for: .tie),
canBetPlayerPair: canAddBet(for: .playerPair),
isBankerPairAtMax: isAtMax(for: .bankerPair),
isTieAtMax: isAtMax(for: .tie),
isPlayerPairAtMax: isAtMax(for: .playerPair),
onBankerPair: { gameState.placeBet(type: .bankerPair, amount: selectedChip.rawValue) },
onTie: { gameState.placeBet(type: .tie, amount: selectedChip.rawValue) },
onPlayerPair: { gameState.placeBet(type: .playerPair, amount: selectedChip.rawValue) }
)
.padding(.horizontal, Design.Spacing.medium)
.padding(.top, Design.Spacing.large)
Spacer(minLength: minSpacerLength)
// BANKER zone in middle
BankerBettingZone(
betAmount: betAmount(for: .banker),
// Middle row: BANKER | DRAGON BONUS
MainBetRow(
title: "BANKER",
payoutText: "PAYS 0.95 TO 1",
mainBetAmount: betAmount(for: .banker),
bonusBetAmount: betAmount(for: .dragonBonusBanker),
isSelected: isBankerSelected,
isEnabled: canAddBet(for: .banker),
isAtMax: isAtMax(for: .banker)
) {
gameState.placeBet(type: .banker, amount: selectedChip.rawValue)
}
.frame(height: mainZoneHeight)
.padding(.horizontal, bankerHorizontalPadding)
canBetMain: canAddBet(for: .banker),
canBetBonus: canAddBet(for: .dragonBonusBanker),
isMainAtMax: isAtMax(for: .banker),
isBonusAtMax: isAtMax(for: .dragonBonusBanker),
mainColor: Color.BettingZone.bankerLight,
mainColorDark: Color.BettingZone.bankerDark,
onMain: { gameState.placeBet(type: .banker, amount: selectedChip.rawValue) },
onBonus: { gameState.placeBet(type: .dragonBonusBanker, amount: selectedChip.rawValue) }
)
.padding(.horizontal, Design.Spacing.medium)
Spacer(minLength: minSpacerLength)
// PLAYER zone at bottom
PlayerBettingZone(
betAmount: betAmount(for: .player),
// Bottom row: PLAYER | DRAGON BONUS
MainBetRow(
title: "PLAYER",
payoutText: "PAYS 1 TO 1",
mainBetAmount: betAmount(for: .player),
bonusBetAmount: betAmount(for: .dragonBonusPlayer),
isSelected: isPlayerSelected,
isEnabled: canAddBet(for: .player),
isAtMax: isAtMax(for: .player)
) {
gameState.placeBet(type: .player, amount: selectedChip.rawValue)
}
.frame(height: mainZoneHeight)
.padding(.horizontal, playerHorizontalPadding)
.padding(.bottom, zoneBottomPadding)
canBetMain: canAddBet(for: .player),
canBetBonus: canAddBet(for: .dragonBonusPlayer),
isMainAtMax: isAtMax(for: .player),
isBonusAtMax: isAtMax(for: .dragonBonusPlayer),
mainColor: Color.BettingZone.playerLight,
mainColorDark: Color.BettingZone.playerDark,
onMain: { gameState.placeBet(type: .player, amount: selectedChip.rawValue) },
onBonus: { gameState.placeBet(type: .dragonBonusPlayer, amount: selectedChip.rawValue) }
)
.padding(.horizontal, Design.Spacing.medium)
.padding(.bottom, Design.Spacing.large)
}
}
.aspectRatio(Design.Size.tableAspectRatio, contentMode: .fit)
@ -144,7 +147,400 @@ struct MiniBaccaratTableView: View {
}
}
/// Custom shape for the mini baccarat table felt.
// MARK: - Top Betting Row (B PAIR | TIE | P PAIR)
private struct TopBettingRow: View {
let bankerPairAmount: Int
let tieAmount: Int
let playerPairAmount: Int
let canBetBankerPair: Bool
let canBetTie: Bool
let canBetPlayerPair: Bool
let isBankerPairAtMax: Bool
let isTieAtMax: Bool
let isPlayerPairAtMax: Bool
let onBankerPair: () -> Void
let onTie: () -> Void
let onPlayerPair: () -> Void
private let rowHeight: CGFloat = 48
private let cornerRadius = Design.CornerRadius.small
var body: some View {
HStack(spacing: Design.Spacing.xSmall) {
// B PAIR
PairBetZone(
title: "B PAIR",
betAmount: bankerPairAmount,
isEnabled: canBetBankerPair,
isAtMax: isBankerPairAtMax,
color: Color.BettingZone.bankerLight,
action: onBankerPair
)
// TIE
TieBetZone(
betAmount: tieAmount,
isEnabled: canBetTie,
isAtMax: isTieAtMax,
action: onTie
)
// P PAIR
PairBetZone(
title: "P PAIR",
betAmount: playerPairAmount,
isEnabled: canBetPlayerPair,
isAtMax: isPlayerPairAtMax,
color: Color.BettingZone.playerLight,
action: onPlayerPair
)
}
.frame(height: rowHeight)
}
}
// MARK: - Pair Bet Zone
private struct PairBetZone: View {
let title: String
let betAmount: Int
let isEnabled: Bool
let isAtMax: Bool
let color: Color
let action: () -> Void
private let cornerRadius = Design.CornerRadius.small
private let titleFontSize: CGFloat = 11
private let payoutFontSize: CGFloat = 9
var body: some View {
Button {
if isEnabled { action() }
} label: {
ZStack {
// Background
RoundedRectangle(cornerRadius: cornerRadius)
.fill(color.opacity(0.6))
// Border
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(isAtMax ? Color.Border.silver : Color.Border.gold, lineWidth: Design.LineWidth.thin)
// Content
VStack(spacing: 1) {
Text(title)
.font(.system(size: titleFontSize, weight: .bold, design: .rounded))
.foregroundStyle(.yellow)
Text("11:1")
.font(.system(size: payoutFontSize, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
}
// Chip indicator
if betAmount > 0 {
SmallChipIndicator(amount: betAmount, isMax: isAtMax)
.offset(x: 0, y: 12)
}
}
}
.buttonStyle(.plain)
.opacity(isEnabled ? 1.0 : 0.6)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(title) bet, pays 11 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : ""))
.accessibilityAddTraits(.isButton)
}
}
// MARK: - Tie Bet Zone
private struct TieBetZone: View {
let betAmount: Int
let isEnabled: Bool
let isAtMax: Bool
let action: () -> Void
private let cornerRadius = Design.CornerRadius.small
private let titleFontSize: CGFloat = 13
private let payoutFontSize: CGFloat = 9
var body: some View {
Button {
if isEnabled { action() }
} label: {
ZStack {
// Background
RoundedRectangle(cornerRadius: cornerRadius)
.fill(isAtMax ? Color.BettingZone.tieMax : Color.BettingZone.tie)
// Border
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(isAtMax ? Color.Border.silver : Color.Border.gold, lineWidth: Design.LineWidth.thin)
// Content
VStack(spacing: 1) {
Text("TIE")
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
.tracking(1)
Text("8 TO 1")
.font(.system(size: payoutFontSize, weight: .medium))
.opacity(0.8)
}
.foregroundStyle(.white)
// Chip indicator
if betAmount > 0 {
SmallChipIndicator(amount: betAmount, isMax: isAtMax)
.offset(x: 0, y: 12)
}
}
}
.buttonStyle(.plain)
.opacity(isEnabled ? 1.0 : 0.6)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Tie bet, pays 8 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : ""))
.accessibilityAddTraits(.isButton)
}
}
// MARK: - Main Bet Row (BANKER/PLAYER with BONUS)
private struct MainBetRow: View {
let title: String
let payoutText: String
let mainBetAmount: Int
let bonusBetAmount: Int
let isSelected: Bool
let canBetMain: Bool
let canBetBonus: Bool
let isMainAtMax: Bool
let isBonusAtMax: Bool
let mainColor: Color
let mainColorDark: Color
let onMain: () -> Void
let onBonus: () -> Void
private let rowHeight: CGFloat = 55
private let bonusWidth: CGFloat = 70
private let cornerRadius = Design.CornerRadius.medium
var body: some View {
HStack(spacing: Design.Spacing.xSmall) {
// Main bet zone (BANKER or PLAYER)
MainBetZone(
title: title,
payoutText: payoutText,
betAmount: mainBetAmount,
isSelected: isSelected,
isEnabled: canBetMain,
isAtMax: isMainAtMax,
colorLight: mainColor,
colorDark: mainColorDark,
action: onMain
)
// Dragon Bonus zone
DragonBonusZone(
betAmount: bonusBetAmount,
isEnabled: canBetBonus,
isAtMax: isBonusAtMax,
action: onBonus
)
.frame(width: bonusWidth)
}
.frame(height: rowHeight)
}
}
// MARK: - Main Bet Zone (BANKER or PLAYER)
private struct MainBetZone: View {
let title: String
let payoutText: String
let betAmount: Int
let isSelected: Bool
let isEnabled: Bool
let isAtMax: Bool
let colorLight: Color
let colorDark: Color
let action: () -> Void
private let cornerRadius = Design.CornerRadius.medium
private let titleFontSize: CGFloat = 18
private let payoutFontSize: CGFloat = 9
var body: some View {
Button {
if isEnabled { action() }
} label: {
ZStack {
// Background gradient
RoundedRectangle(cornerRadius: cornerRadius)
.fill(
LinearGradient(
colors: isAtMax ? [colorLight.opacity(0.5), colorDark.opacity(0.5)] : [colorLight, colorDark],
startPoint: .top,
endPoint: .bottom
)
)
// Selection glow
if isSelected {
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(Color.yellow, lineWidth: Design.LineWidth.thick)
.shadow(color: .yellow.opacity(0.5), radius: 6)
}
// Border
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(isAtMax ? Color.Border.silver : Color.Border.gold, lineWidth: Design.LineWidth.medium)
// Content
VStack(spacing: 2) {
Text(title)
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
.tracking(2)
Text(payoutText)
.font(.system(size: payoutFontSize, weight: .medium))
.opacity(0.8)
}
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
// Chip indicator
if betAmount > 0 {
HStack {
Spacer()
ChipOnTableView(amount: betAmount, showMax: isAtMax)
.padding(.trailing, Design.Spacing.small)
}
}
}
}
.buttonStyle(.plain)
.opacity(isEnabled ? 1.0 : 0.6)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(title) bet, \(payoutText)" + (isSelected ? ", selected" : "") + (betAmount > 0 ? ", current bet $\(betAmount)" : ""))
.accessibilityAddTraits(.isButton)
}
}
// MARK: - Dragon Bonus Zone
private struct DragonBonusZone: View {
let betAmount: Int
let isEnabled: Bool
let isAtMax: Bool
let action: () -> Void
private let cornerRadius = Design.CornerRadius.medium
private let titleFontSize: CGFloat = 10
private let diamondSize: CGFloat = 20
var body: some View {
Button {
if isEnabled { action() }
} label: {
ZStack {
// Background
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Color.purple.opacity(0.5))
// Border
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(isAtMax ? Color.Border.silver : Color.Border.gold, lineWidth: Design.LineWidth.thin)
// Content
VStack(spacing: 4) {
// Diamond shape
DiamondShape()
.fill(Color.purple.opacity(0.8))
.frame(width: diamondSize, height: diamondSize)
.overlay(
DiamondShape()
.strokeBorder(Color.white.opacity(0.5), lineWidth: 1)
)
Text("BONUS")
.font(.system(size: titleFontSize, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
// Chip indicator
if betAmount > 0 {
SmallChipIndicator(amount: betAmount, isMax: isAtMax)
.offset(y: 18)
}
}
}
.buttonStyle(.plain)
.opacity(isEnabled ? 1.0 : 0.6)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Dragon Bonus, pays up to 30 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : ""))
.accessibilityAddTraits(.isButton)
}
}
// MARK: - Diamond Shape
private struct DiamondShape: InsettableShape {
var insetAmount: CGFloat = 0
func path(in rect: CGRect) -> Path {
var path = Path()
let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount)
path.move(to: CGPoint(x: insetRect.midX, y: insetRect.minY))
path.addLine(to: CGPoint(x: insetRect.maxX, y: insetRect.midY))
path.addLine(to: CGPoint(x: insetRect.midX, y: insetRect.maxY))
path.addLine(to: CGPoint(x: insetRect.minX, y: insetRect.midY))
path.closeSubpath()
return path
}
func inset(by amount: CGFloat) -> some InsettableShape {
var shape = self
shape.insetAmount += amount
return shape
}
}
// MARK: - Small Chip Indicator
private struct SmallChipIndicator: View {
let amount: Int
let isMax: Bool
var body: some View {
ZStack {
Circle()
.fill(isMax ? Color.gray : Color.yellow)
.frame(width: 18, height: 18)
Circle()
.strokeBorder(Color.white, lineWidth: 1)
.frame(width: 18, height: 18)
if isMax {
Text("M")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
} else {
Text(amount.formatted(.number.notation(.compactName)))
.font(.system(size: 6, weight: .bold))
.foregroundStyle(.black)
}
}
}
}
// MARK: - Table Felt Shape
struct TableFeltShape: InsettableShape {
var insetAmount: CGFloat = 0
@ -198,352 +594,6 @@ struct TableFeltShape: InsettableShape {
}
}
/// The TIE betting zone at the top of the table.
struct TieBettingZone: View {
let betAmount: Int
let isEnabled: Bool
var isAtMax: Bool = false
let action: () -> Void
// MARK: - Fixed Font Sizes
// Fixed because betting zones have strict space constraints
private let titleFontSize: CGFloat = Design.BaseFontSize.medium
private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall
// MARK: - Accessibility
private var accessibilityDescription: String {
var description = String(localized: "Tie bet, pays 8 to 1")
if betAmount > 0 {
let format = String(localized: "currentBetFormat")
description += ". " + String(format: format, betAmount.formatted())
if isAtMax {
description += ", " + String(localized: "maximum bet")
}
}
return description
}
private var accessibilityHintText: String {
if isEnabled {
return String(localized: "Double tap to place bet")
} else if isAtMax {
return String(localized: "Maximum bet reached")
} else {
return String(localized: "Betting disabled")
}
}
// MARK: - Layout Constants
private let cornerRadius = Design.CornerRadius.small
private let chipTrailingPadding = Design.Spacing.small
// MARK: - Computed Properties
private var backgroundColor: Color {
isAtMax ? Color.BettingZone.tieMax : Color.BettingZone.tie
}
private var borderColor: Color {
isAtMax ? Color.Border.silver : Color.Border.gold
}
// MARK: - Body
var body: some View {
Button {
if isEnabled {
action()
}
} label: {
ZStack {
// Background
RoundedRectangle(cornerRadius: cornerRadius)
.fill(backgroundColor)
// Border
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(borderColor, lineWidth: Design.LineWidth.medium)
// Centered text content
VStack(spacing: Design.Spacing.xxSmall) {
Text("TIE")
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
.tracking(2)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
Text("PAYS 8 TO 1")
.font(.system(size: subtitleFontSize, weight: .medium))
.opacity(Design.Opacity.heavy)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
}
.foregroundStyle(.white)
}
// Chip overlaid on right side
.overlay(alignment: .trailing) {
if betAmount > 0 {
ChipOnTableView(amount: betAmount, showMax: isAtMax)
.padding(.trailing, chipTrailingPadding)
.accessibilityHidden(true) // Included in zone description
}
}
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription)
.accessibilityHint(accessibilityHintText)
.accessibilityAddTraits(.isButton)
}
}
/// The BANKER betting zone in the middle of the table.
struct BankerBettingZone: View {
let betAmount: Int
let isSelected: Bool
let isEnabled: Bool
var isAtMax: Bool = false
let action: () -> Void
// MARK: - Fixed Font Sizes
// Fixed because betting zones have strict space constraints
private let titleFontSize: CGFloat = Design.BaseFontSize.large
private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall
// MARK: - Accessibility
private var accessibilityDescription: String {
var description = String(localized: "Banker bet, pays 0.95 to 1")
if isSelected {
description += ", " + String(localized: "selected")
}
if betAmount > 0 {
let format = String(localized: "currentBetFormat")
description += ". " + String(format: format, betAmount.formatted())
if isAtMax {
description += ", " + String(localized: "maximum bet")
}
}
return description
}
private var accessibilityHintText: String {
if isEnabled {
return String(localized: "Double tap to place bet")
} else if isAtMax {
return String(localized: "Maximum bet reached")
} else {
return String(localized: "Betting disabled")
}
}
// MARK: - Layout Constants
private let cornerRadius = Design.CornerRadius.medium
private let chipTrailingPadding = Design.Spacing.medium
private let selectionShadowRadius = Design.Shadow.radiusSmall
// MARK: - Computed Properties
private var backgroundColors: [Color] {
isAtMax
? [Color.BettingZone.bankerMaxLight, Color.BettingZone.bankerMaxDark]
: [Color.BettingZone.bankerLight, Color.BettingZone.bankerDark]
}
private var borderColor: Color {
isAtMax ? Color.Border.silver : Color.Border.gold
}
// MARK: - Body
var body: some View {
Button {
if isEnabled {
action()
}
} label: {
ZStack {
// Background
RoundedRectangle(cornerRadius: cornerRadius)
.fill(
LinearGradient(
colors: backgroundColors,
startPoint: .top,
endPoint: .bottom
)
)
// Selection glow
if isSelected {
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(Color.yellow, lineWidth: Design.LineWidth.thick)
.shadow(color: .yellow.opacity(Design.Opacity.medium), radius: selectionShadowRadius)
}
// Border
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(borderColor, lineWidth: Design.LineWidth.medium)
// Centered text content
VStack(spacing: Design.Spacing.xxSmall) {
Text("BANKER")
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
.tracking(3)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
Text("PAYS 0.95 TO 1")
.font(.system(size: subtitleFontSize, weight: .medium))
.opacity(Design.Opacity.heavy)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
}
.foregroundStyle(.white)
}
// Chip overlaid on right side
.overlay(alignment: .trailing) {
if betAmount > 0 {
ChipOnTableView(amount: betAmount, showMax: isAtMax)
.padding(.trailing, chipTrailingPadding)
.accessibilityHidden(true) // Included in zone description
}
}
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription)
.accessibilityHint(accessibilityHintText)
.accessibilityAddTraits(.isButton)
}
}
/// The PLAYER betting zone at the bottom of the table.
struct PlayerBettingZone: View {
let betAmount: Int
let isSelected: Bool
let isEnabled: Bool
var isAtMax: Bool = false
let action: () -> Void
// MARK: - Fixed Font Sizes
// Fixed because betting zones have strict space constraints
private let titleFontSize: CGFloat = Design.BaseFontSize.large
private let subtitleFontSize: CGFloat = Design.BaseFontSize.xSmall
// MARK: - Accessibility
private var accessibilityDescription: String {
var description = String(localized: "Player bet, pays 1 to 1")
if isSelected {
description += ", " + String(localized: "selected")
}
if betAmount > 0 {
let format = String(localized: "currentBetFormat")
description += ". " + String(format: format, betAmount.formatted())
if isAtMax {
description += ", " + String(localized: "maximum bet")
}
}
return description
}
private var accessibilityHintText: String {
if isEnabled {
return String(localized: "Double tap to place bet")
} else if isAtMax {
return String(localized: "Maximum bet reached")
} else {
return String(localized: "Betting disabled")
}
}
// MARK: - Layout Constants
private let cornerRadius = Design.CornerRadius.medium
private let chipTrailingPadding = Design.Spacing.medium
private let selectionShadowRadius = Design.Shadow.radiusSmall
// MARK: - Computed Properties
private var backgroundColors: [Color] {
isAtMax
? [Color.BettingZone.playerMaxLight, Color.BettingZone.playerMaxDark]
: [Color.BettingZone.playerLight, Color.BettingZone.playerDark]
}
private var borderColor: Color {
isAtMax ? Color.Border.silver : Color.Border.gold
}
// MARK: - Body
var body: some View {
Button {
if isEnabled {
action()
}
} label: {
ZStack {
// Background
RoundedRectangle(cornerRadius: cornerRadius)
.fill(
LinearGradient(
colors: backgroundColors,
startPoint: .top,
endPoint: .bottom
)
)
// Selection glow
if isSelected {
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(Color.yellow, lineWidth: Design.LineWidth.thick)
.shadow(color: .yellow.opacity(Design.Opacity.medium), radius: selectionShadowRadius)
}
// Border
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(borderColor, lineWidth: Design.LineWidth.medium)
// Centered text content
VStack(spacing: Design.Spacing.xxSmall) {
Text("PLAYER")
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
.tracking(3)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
Text("PAYS 1 TO 1")
.font(.system(size: subtitleFontSize, weight: .medium))
.opacity(Design.Opacity.heavy)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
}
.foregroundStyle(.white)
}
// Chip overlaid on right side
.overlay(alignment: .trailing) {
if betAmount > 0 {
ChipOnTableView(amount: betAmount, showMax: isAtMax)
.padding(.trailing, chipTrailingPadding)
.accessibilityHidden(true) // Included in zone description
}
}
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription)
.accessibilityHint(accessibilityHintText)
.accessibilityAddTraits(.isButton)
}
}
#Preview {
ZStack {
Color.Table.baseDark

View File

@ -12,15 +12,20 @@ import CasinoKit
struct ResultBannerView: View {
let result: GameResult
let winnings: Int
var playerHadPair: Bool = false
var bankerHadPair: Bool = false
var sideBetWinnings: [String] = [] // List of side bet win descriptions
@State private var showBanner = false
@State private var showText = false
@State private var showWinnings = false
@State private var showSideBets = false
// MARK: - Scaled Font Sizes (Dynamic Type)
@ScaledMetric(relativeTo: .largeTitle) private var resultFontSize: CGFloat = Design.BaseFontSize.largeTitle
@ScaledMetric(relativeTo: .title2) private var winningsFontSize: CGFloat = 28
@ScaledMetric(relativeTo: .body) private var sideBetFontSize: CGFloat = 14
var body: some View {
ZStack {
@ -30,7 +35,7 @@ struct ResultBannerView: View {
.animation(.easeIn(duration: Design.Animation.fadeInDuration), value: showBanner)
// Banner
VStack(spacing: Design.Spacing.xLarge) {
VStack(spacing: Design.Spacing.medium) {
// Result text
Text(result.displayText)
.font(.system(size: resultFontSize, weight: .black, design: .rounded))
@ -45,6 +50,20 @@ struct ResultBannerView: View {
.scaleEffect(showText ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showText ? Design.Scale.normal : 0)
// Pair indicators
if playerHadPair || bankerHadPair {
HStack(spacing: Design.Spacing.large) {
if playerHadPair {
PairBadge(label: "P PAIR", color: .blue)
}
if bankerHadPair {
PairBadge(label: "B PAIR", color: .red)
}
}
.scaleEffect(showSideBets ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showSideBets ? Design.Scale.normal : 0)
}
// Winnings display
if winnings != 0 {
HStack(spacing: Design.Spacing.small) {
@ -64,6 +83,20 @@ struct ResultBannerView: View {
.scaleEffect(showWinnings ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showWinnings ? Design.Scale.normal : 0)
}
// Side bet win descriptions
if !sideBetWinnings.isEmpty {
VStack(spacing: Design.Spacing.xSmall) {
ForEach(sideBetWinnings, id: \.self) { description in
Text(description)
.font(.system(size: sideBetFontSize, weight: .semibold))
.foregroundStyle(.yellow)
}
}
.padding(.top, Design.Spacing.xSmall)
.scaleEffect(showSideBets ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showSideBets ? Design.Scale.normal : 0)
}
}
.padding(Design.Spacing.xxxLarge + Design.Spacing.small)
.background(
@ -110,6 +143,10 @@ struct ResultBannerView: View {
showWinnings = true
}
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay2 + 0.1)) {
showSideBets = true
}
// Announce result to VoiceOver users
announceResult()
}
@ -122,6 +159,16 @@ struct ResultBannerView: View {
private var accessibilityDescription: String {
var description = result.displayText
// Add pair information
if playerHadPair {
description += ". Player pair"
}
if bankerHadPair {
description += ". Banker pair"
}
// Add winnings
if winnings > 0 {
let format = String(localized: "wonAmountFormat")
description += ". " + String(format: format, winnings.formatted())
@ -129,6 +176,12 @@ struct ResultBannerView: View {
let format = String(localized: "lostAmountFormat")
description += ". " + String(format: format, abs(winnings).formatted())
}
// Add side bet descriptions
for sideBet in sideBetWinnings {
description += ". \(sideBet)"
}
return description
}
@ -141,6 +194,24 @@ struct ResultBannerView: View {
}
}
/// A small badge showing pair result.
private struct PairBadge: View {
let label: String
let color: Color
var body: some View {
Text(label)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xxSmall)
.background(
Capsule()
.fill(color)
)
}
}
/// Confetti particle for celebrations.
struct ConfettiPiece: View {
let color: Color
@ -195,7 +266,12 @@ struct ConfettiView: View {
Color.Table.preview
.ignoresSafeArea()
ResultBannerView(result: .playerWins, winnings: 500)
ResultBannerView(
result: .playerWins,
winnings: 500,
playerHadPair: true,
bankerHadPair: false,
sideBetWinnings: ["Dragon Bonus +300"]
)
}
}

View File

@ -0,0 +1,468 @@
//
// RulesHelpView.swift
// Baccarat
//
// A paginated help view explaining game rules and side bets.
//
import SwiftUI
/// The available rule pages.
enum RulesPage: Int, CaseIterable, Identifiable {
case basicRules = 0
case thirdCardRules = 1
case dragonBonus = 2
case pairBonus = 3
var id: Int { rawValue }
var title: String {
switch self {
case .basicRules: return String(localized: "How to Play")
case .thirdCardRules: return String(localized: "Third Card Rules")
case .dragonBonus: return String(localized: "Dragon Bonus")
case .pairBonus: return String(localized: "Pair Bonus")
}
}
}
/// A multi-page help view explaining baccarat rules.
struct RulesHelpView: View {
@Environment(\.dismiss) private var dismiss
@State private var currentPage: RulesPage = .basicRules
// MARK: - Layout Constants
private let modalCornerRadius = Design.CornerRadius.xxxLarge
private let contentCornerRadius = Design.CornerRadius.xLarge
private let contentPadding = Design.Spacing.large
private let buttonSize: CGFloat = 44
// MARK: - Body
var body: some View {
ZStack {
// Background
Color.black.opacity(0.9)
.ignoresSafeArea()
VStack(spacing: Design.Spacing.medium) {
// Header with logo
headerView
// Content card
contentCard
.padding(.horizontal)
// Navigation
navigationView
.padding(.bottom, Design.Spacing.large)
}
}
}
// MARK: - Subviews
private var headerView: some View {
VStack(spacing: Design.Spacing.small) {
// Cards icon
HStack(spacing: -Design.Spacing.small) {
Image(systemName: "suit.spade.fill")
.foregroundStyle(.white)
Image(systemName: "suit.heart.fill")
.foregroundStyle(.red)
}
.font(.system(size: 32))
Text("BACCARAT")
.font(.system(size: 28, weight: .black, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [.yellow, .orange],
startPoint: .top,
endPoint: .bottom
)
)
.tracking(3)
}
.padding(.top, Design.Spacing.xLarge)
}
private var contentCard: some View {
VStack(spacing: 0) {
// Page title
Text(currentPage.title)
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundStyle(.yellow)
.padding(.top, Design.Spacing.large)
.padding(.bottom, Design.Spacing.medium)
// Scrollable content
ScrollView {
pageContent
.padding(.horizontal, contentPadding)
.padding(.bottom, Design.Spacing.large)
}
}
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: contentCornerRadius)
.fill(Color(red: 0.15, green: 0.35, blue: 0.55))
)
.clipShape(RoundedRectangle(cornerRadius: contentCornerRadius))
}
@ViewBuilder
private var pageContent: some View {
switch currentPage {
case .basicRules:
BasicRulesContent()
case .thirdCardRules:
ThirdCardRulesContent()
case .dragonBonus:
DragonBonusContent()
case .pairBonus:
PairBonusContent()
}
}
private var navigationView: some View {
HStack(spacing: Design.Spacing.medium) {
// Previous button
Button {
withAnimation(.spring(duration: 0.3)) {
goToPreviousPage()
}
} label: {
Image(systemName: "chevron.left.circle.fill")
.font(.system(size: 36))
.foregroundStyle(currentPage.rawValue > 0 ? .yellow : .gray.opacity(0.5))
}
.disabled(currentPage.rawValue == 0)
// Back to game button
Button {
dismiss()
} label: {
Text("BACK TO GAME")
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.medium)
.background(
Capsule()
.fill(
LinearGradient(
colors: [Color.Button.goldLight, Color.Button.goldDark],
startPoint: .top,
endPoint: .bottom
)
)
)
}
// Next button
Button {
withAnimation(.spring(duration: 0.3)) {
goToNextPage()
}
} label: {
Image(systemName: "chevron.right.circle.fill")
.font(.system(size: 36))
.foregroundStyle(currentPage.rawValue < RulesPage.allCases.count - 1 ? .green : .gray.opacity(0.5))
}
.disabled(currentPage.rawValue >= RulesPage.allCases.count - 1)
}
}
// MARK: - Navigation
private func goToPreviousPage() {
if currentPage.rawValue > 0 {
currentPage = RulesPage(rawValue: currentPage.rawValue - 1) ?? .basicRules
}
}
private func goToNextPage() {
if currentPage.rawValue < RulesPage.allCases.count - 1 {
currentPage = RulesPage(rawValue: currentPage.rawValue + 1) ?? .pairBonus
}
}
}
// MARK: - Basic Rules Content
private struct BasicRulesContent: View {
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
RuleSection(text: "Two hands are dealt: one for the Player and one for the Banker. You may bet on which hand will win, or that they will tie.")
RuleSection(title: "Payouts", items: [
"Player wins: pays 1 to 1",
"Banker wins: pays 0.95 to 1 (5% commission)",
"Tie: pays 8 to 1"
])
Divider()
.background(Color.white.opacity(0.3))
Text("Card Values")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
RuleSection(items: [
"Aces = 1 point",
"2-9 = Face value",
"10, J, Q, K = 0 points"
])
RuleSection(text: "Hand values are the sum of cards, keeping only the last digit. For example: 7 + 8 = 15, so the hand value is 5.")
Divider()
.background(Color.white.opacity(0.3))
Text("Natural Win")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
RuleSection(text: "If either hand totals 8 or 9 with the first two cards, it's a \"Natural\" and the round ends immediately.")
}
}
}
// MARK: - Third Card Rules Content
private struct ThirdCardRulesContent: View {
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
RuleSection(text: "If neither hand has a Natural, additional cards may be drawn according to fixed rules.")
Divider()
.background(Color.white.opacity(0.3))
Text("Player Rules")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
RuleSection(items: [
"0-5: Player draws a third card",
"6-7: Player stands"
])
Divider()
.background(Color.white.opacity(0.3))
Text("Banker Rules")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
RuleSection(text: "If Player stood (6-7), Banker draws on 0-5 and stands on 6-7.")
RuleSection(text: "If Player drew a third card, Banker's action depends on both the Banker's total and the Player's third card:")
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
BankerRuleRow(bankerTotal: "0-2", action: "Always draws")
BankerRuleRow(bankerTotal: "3", action: "Draws unless Player's 3rd was 8")
BankerRuleRow(bankerTotal: "4", action: "Draws if Player's 3rd was 2-7")
BankerRuleRow(bankerTotal: "5", action: "Draws if Player's 3rd was 4-7")
BankerRuleRow(bankerTotal: "6", action: "Draws if Player's 3rd was 6-7")
BankerRuleRow(bankerTotal: "7", action: "Always stands")
}
.padding()
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(0.2))
)
}
}
}
private struct BankerRuleRow: View {
let bankerTotal: String
let action: String
var body: some View {
HStack {
Text("Banker \(bankerTotal):")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.yellow)
.frame(width: 80, alignment: .leading)
Text(action)
.font(.system(size: 13))
.foregroundStyle(.white.opacity(0.9))
}
}
}
// MARK: - Dragon Bonus Content
private struct DragonBonusContent: View {
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
RuleSection(text: "The Dragon Bonus is a side bet available for both Player and Banker. It pays based on how the winning hand wins.")
Divider()
.background(Color.white.opacity(0.3))
Text("Payout Table")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
VStack(spacing: Design.Spacing.xSmall) {
PayoutRow(condition: "Natural Win (8 or 9)", payout: "1 to 1")
PayoutRow(condition: "Win by 9 points", payout: "30 to 1")
PayoutRow(condition: "Win by 8 points", payout: "10 to 1")
PayoutRow(condition: "Win by 7 points", payout: "6 to 1")
PayoutRow(condition: "Win by 6 points", payout: "4 to 1")
PayoutRow(condition: "Win by 5 points", payout: "2 to 1")
PayoutRow(condition: "Win by 4 points", payout: "1 to 1")
}
.padding()
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(0.2))
)
Divider()
.background(Color.white.opacity(0.3))
Text("Important")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
RuleSection(items: [
"Dragon Bonus loses if your side doesn't win",
"Dragon Bonus loses on a tie",
"Wins by less than 4 points also lose"
])
}
}
}
private struct PayoutRow: View {
let condition: String
let payout: String
var body: some View {
HStack {
Text(condition)
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.9))
Spacer()
Text(payout)
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.yellow)
}
.padding(.vertical, Design.Spacing.xxSmall)
}
}
// MARK: - Pair Bonus Content
private struct PairBonusContent: View {
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
RuleSection(text: "Pair Bonus bets are available for both Player and Banker. They pay when the first two cards dealt to that hand form a pair.")
Divider()
.background(Color.white.opacity(0.3))
Text("Payout")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
HStack {
VStack {
Text("11:1")
.font(.system(size: 48, weight: .black, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [.yellow, .orange],
startPoint: .top,
endPoint: .bottom
)
)
Text("Pair Pays")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.7))
}
.frame(maxWidth: .infinity)
}
.padding(.vertical, Design.Spacing.medium)
Divider()
.background(Color.white.opacity(0.3))
Text("Examples")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
RuleSection(items: [
"5♥ + 5♣ = Pair (wins 11:1)",
"J♦ + J♠ = Pair (wins 11:1)",
"A♥ + A♥ = Pair (wins 11:1)"
])
RuleSection(text: "Note: Suits are disregarded. Only the rank matters for a pair.")
Divider()
.background(Color.white.opacity(0.3))
Text("Tips")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
RuleSection(items: [
"Pair bets are independent of the main game result",
"You can bet on Player Pair, Banker Pair, or both",
"Pairs occur roughly once every 15 hands"
])
}
}
}
// MARK: - Helper Views
private struct RuleSection: View {
var title: String? = nil
var text: String? = nil
var items: [String]? = nil
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
if let title = title {
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.yellow)
}
if let text = text {
Text(text)
.font(.system(size: 14))
.foregroundStyle(.white.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
}
if let items = items {
ForEach(items, id: \.self) { item in
HStack(alignment: .top, spacing: Design.Spacing.small) {
Text("")
.foregroundStyle(.yellow)
Text(item)
.foregroundStyle(.white.opacity(0.9))
}
.font(.system(size: 14))
}
}
}
}
}
#Preview {
RulesHelpView()
}