Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
41eb7cd66c
commit
e1d1f29793
@ -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",
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
468
Baccarat/Views/RulesHelpView.swift
Normal file
468
Baccarat/Views/RulesHelpView.swift
Normal 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()
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user