diff --git a/Baccarat.xcodeproj/project.pbxproj b/Baccarat.xcodeproj/project.pbxproj index 6ec54bb..511ab95 100644 --- a/Baccarat.xcodeproj/project.pbxproj +++ b/Baccarat.xcodeproj/project.pbxproj @@ -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", diff --git a/Baccarat/Engine/BaccaratEngine.swift b/Baccarat/Engine/BaccaratEngine.swift index fd08c8e..408753a 100644 --- a/Baccarat/Engine/BaccaratEngine.swift +++ b/Baccarat/Engine/BaccaratEngine.swift @@ -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() } } - diff --git a/Baccarat/Engine/GameState.swift b/Baccarat/Engine/GameState.swift index f5e2fc5..4eedde1 100644 --- a/Baccarat/Engine/GameState.swift +++ b/Baccarat/Engine/GameState.swift @@ -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() } } - diff --git a/Baccarat/Models/BetType.swift b/Baccarat/Models/BetType.swift index f17659c..69d1790 100644 --- a/Baccarat/Models/BetType.swift +++ b/Baccarat/Models/BetType.swift @@ -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") + ] + } +} diff --git a/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Resources/Localizable.xcstrings index 1248250..09aa46b 100644 --- a/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Baccarat/Views/GameTableView.swift b/Baccarat/Views/GameTableView.swift index 2881587..10305df 100644 --- a/Baccarat/Views/GameTableView.swift +++ b/Baccarat/Views/GameTableView.swift @@ -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) diff --git a/Baccarat/Views/MiniBaccaratTableView.swift b/Baccarat/Views/MiniBaccaratTableView.swift index f543f89..d35da41 100644 --- a/Baccarat/Views/MiniBaccaratTableView.swift +++ b/Baccarat/Views/MiniBaccaratTableView.swift @@ -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) - - Spacer(minLength: minSpacerLength) + 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) - // 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 diff --git a/Baccarat/Views/ResultBannerView.swift b/Baccarat/Views/ResultBannerView.swift index 210c7af..af9000f 100644 --- a/Baccarat/Views/ResultBannerView.swift +++ b/Baccarat/Views/ResultBannerView.swift @@ -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"] + ) } } - diff --git a/Baccarat/Views/RulesHelpView.swift b/Baccarat/Views/RulesHelpView.swift new file mode 100644 index 0000000..f07faf8 --- /dev/null +++ b/Baccarat/Views/RulesHelpView.swift @@ -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() +} +