Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
2dd9ae020e
commit
be9fc77605
@ -36,6 +36,23 @@ final class GameState {
|
||||
/// Insurance bet amount.
|
||||
var insuranceBet: Int = 0
|
||||
|
||||
// MARK: - Side Bets
|
||||
|
||||
/// Perfect Pairs side bet amount.
|
||||
var perfectPairsBet: Int = 0
|
||||
|
||||
/// 21+3 side bet amount.
|
||||
var twentyOnePlusThreeBet: Int = 0
|
||||
|
||||
/// Result of Perfect Pairs bet (set after deal).
|
||||
var perfectPairsResult: PerfectPairsResult?
|
||||
|
||||
/// Result of 21+3 bet (set after deal).
|
||||
var twentyOnePlusThreeResult: TwentyOnePlusThreeResult?
|
||||
|
||||
/// Whether to show side bet toast notifications.
|
||||
var showSideBetToasts: Bool = false
|
||||
|
||||
/// Whether a reshuffle notification should be shown.
|
||||
var showReshuffleNotification: Bool = false
|
||||
|
||||
@ -105,8 +122,57 @@ final class GameState {
|
||||
|
||||
/// Whether player can place a bet.
|
||||
/// True if in betting phase, have balance, and haven't hit max bet.
|
||||
var canBet: Bool {
|
||||
currentPhase == .betting && balance > 0 && currentBet < settings.maxBet
|
||||
/// Whether in betting phase (matches Baccarat's canPlaceBet).
|
||||
var canPlaceBet: Bool {
|
||||
currentPhase == .betting
|
||||
}
|
||||
|
||||
/// Whether the main bet can accept more chips of the given amount.
|
||||
/// Matches Baccarat's canAddToBet pattern.
|
||||
func canAddToMainBet(amount: Int) -> Bool {
|
||||
currentBet + amount <= settings.maxBet
|
||||
}
|
||||
|
||||
/// Whether a specific side bet can accept more chips of the given amount.
|
||||
/// Matches Baccarat's canAddToBet pattern.
|
||||
func canAddToSideBet(type: SideBetType, amount: Int) -> Bool {
|
||||
guard settings.sideBetsEnabled else { return false }
|
||||
let currentAmount: Int
|
||||
switch type {
|
||||
case .perfectPairs:
|
||||
currentAmount = perfectPairsBet
|
||||
case .twentyOnePlusThree:
|
||||
currentAmount = twentyOnePlusThreeBet
|
||||
}
|
||||
return currentAmount + amount <= settings.maxBet
|
||||
}
|
||||
|
||||
/// Whether a bet type is at max.
|
||||
func isAtMax(main: Bool = false, sideType: SideBetType? = nil) -> Bool {
|
||||
if main {
|
||||
return currentBet >= settings.maxBet
|
||||
}
|
||||
if let type = sideType {
|
||||
switch type {
|
||||
case .perfectPairs:
|
||||
return perfectPairsBet >= settings.maxBet
|
||||
case .twentyOnePlusThree:
|
||||
return twentyOnePlusThreeBet >= settings.maxBet
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// The minimum bet level across all active bet types.
|
||||
/// Used by chip selector to determine if chips should be enabled.
|
||||
/// Returns the smallest bet so chips stay enabled if ANY bet type can accept more.
|
||||
var minBetForChipSelector: Int {
|
||||
if settings.sideBetsEnabled {
|
||||
// Return the minimum of all bet types so chips stay enabled if any can be increased
|
||||
return min(currentBet, perfectPairsBet, twentyOnePlusThreeBet)
|
||||
} else {
|
||||
return currentBet
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the current hand can hit.
|
||||
@ -315,24 +381,49 @@ final class GameState {
|
||||
|
||||
// MARK: - Betting
|
||||
|
||||
/// Places a bet.
|
||||
/// Places a main bet.
|
||||
/// Places a main bet. Matches Baccarat's placeBet pattern.
|
||||
func placeBet(amount: Int) {
|
||||
guard canBet else { return }
|
||||
guard currentBet + amount <= settings.maxBet else { return }
|
||||
guard canPlaceBet else { return }
|
||||
guard balance >= amount else { return }
|
||||
guard canAddToMainBet(amount: amount) else { return }
|
||||
|
||||
currentBet += amount
|
||||
balance -= amount
|
||||
sound.play(.chipPlace)
|
||||
}
|
||||
|
||||
/// Clears the current bet.
|
||||
func clearBet() {
|
||||
balance += currentBet
|
||||
currentBet = 0
|
||||
/// Places a side bet (Perfect Pairs or 21+3).
|
||||
/// Matches Baccarat's placeBet pattern for side bets.
|
||||
func placeSideBet(type: SideBetType, amount: Int) {
|
||||
guard canPlaceBet else { return }
|
||||
guard balance >= amount else { return }
|
||||
guard canAddToSideBet(type: type, amount: amount) else { return }
|
||||
|
||||
switch type {
|
||||
case .perfectPairs:
|
||||
perfectPairsBet += amount
|
||||
case .twentyOnePlusThree:
|
||||
twentyOnePlusThreeBet += amount
|
||||
}
|
||||
balance -= amount
|
||||
sound.play(.chipPlace)
|
||||
}
|
||||
|
||||
/// Clears all bets (main and side bets).
|
||||
func clearBet() {
|
||||
balance += currentBet + perfectPairsBet + twentyOnePlusThreeBet
|
||||
currentBet = 0
|
||||
perfectPairsBet = 0
|
||||
twentyOnePlusThreeBet = 0
|
||||
sound.play(.chipPlace)
|
||||
}
|
||||
|
||||
/// Total amount bet (main + side bets).
|
||||
var totalBetAmount: Int {
|
||||
currentBet + perfectPairsBet + twentyOnePlusThreeBet
|
||||
}
|
||||
|
||||
// MARK: - Dealing
|
||||
|
||||
/// Whether the player can deal (betting phase with valid bet).
|
||||
@ -360,6 +451,8 @@ final class GameState {
|
||||
dealerHand = BlackjackHand()
|
||||
activeHandIndex = 0
|
||||
insuranceBet = 0
|
||||
perfectPairsResult = nil
|
||||
twentyOnePlusThreeResult = nil
|
||||
|
||||
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
|
||||
|
||||
@ -381,6 +474,9 @@ final class GameState {
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate side bets after initial cards are dealt
|
||||
evaluateSideBets()
|
||||
|
||||
// Check for insurance offer (only in American style with hole card)
|
||||
if !settings.noHoleCard, let upCard = dealerUpCard, engine.shouldOfferInsurance(dealerUpCard: upCard) {
|
||||
currentPhase = .insurance
|
||||
@ -652,6 +748,66 @@ final class GameState {
|
||||
await completeRound()
|
||||
}
|
||||
|
||||
// MARK: - Side Bets
|
||||
|
||||
/// Evaluates side bets based on the initial deal.
|
||||
private func evaluateSideBets() {
|
||||
guard settings.sideBetsEnabled else { return }
|
||||
|
||||
let playerCards = playerHands[0].cards
|
||||
guard playerCards.count >= 2 else { return }
|
||||
|
||||
// Evaluate Perfect Pairs
|
||||
if perfectPairsBet > 0 {
|
||||
perfectPairsResult = SideBetEvaluator.evaluatePerfectPairs(
|
||||
card1: playerCards[0],
|
||||
card2: playerCards[1]
|
||||
)
|
||||
}
|
||||
|
||||
// Evaluate 21+3 (requires dealer upcard)
|
||||
if twentyOnePlusThreeBet > 0, let dealerUpCard = dealerUpCard {
|
||||
twentyOnePlusThreeResult = SideBetEvaluator.evaluateTwentyOnePlusThree(
|
||||
playerCard1: playerCards[0],
|
||||
playerCard2: playerCards[1],
|
||||
dealerUpcard: dealerUpCard
|
||||
)
|
||||
}
|
||||
|
||||
// Show toast notifications if any side bets were placed
|
||||
if perfectPairsBet > 0 || twentyOnePlusThreeBet > 0 {
|
||||
showSideBetToasts = true
|
||||
|
||||
// Play sound for side bet result
|
||||
let ppWon = perfectPairsResult?.isWin ?? false
|
||||
let topWon = twentyOnePlusThreeResult?.isWin ?? false
|
||||
if ppWon || topWon {
|
||||
sound.play(.win)
|
||||
}
|
||||
|
||||
// Auto-hide toasts after delay
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
showSideBetToasts = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates winnings from side bets.
|
||||
var sideBetWinnings: Int {
|
||||
var winnings = 0
|
||||
|
||||
if let ppResult = perfectPairsResult, ppResult.isWin {
|
||||
winnings += perfectPairsBet * ppResult.payout
|
||||
}
|
||||
|
||||
if let topResult = twentyOnePlusThreeResult, topResult.isWin {
|
||||
winnings += twentyOnePlusThreeBet * topResult.payout
|
||||
}
|
||||
|
||||
return winnings
|
||||
}
|
||||
|
||||
// MARK: - Round Completion
|
||||
|
||||
/// Completes the round and calculates payouts.
|
||||
@ -712,6 +868,17 @@ final class GameState {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate side bet results
|
||||
let sideBetsTotal = perfectPairsBet + twentyOnePlusThreeBet
|
||||
let sideBetsWon = sideBetWinnings
|
||||
if sideBetsWon > 0 {
|
||||
balance += sideBetsWon + sideBetsTotal // Return bet + winnings
|
||||
roundWinnings += sideBetsWon
|
||||
} else if sideBetsTotal > 0 {
|
||||
// Side bets lost - account for the loss in round winnings
|
||||
roundWinnings -= sideBetsTotal
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
totalWinnings += roundWinnings
|
||||
if roundWinnings > biggestWin {
|
||||
@ -727,13 +894,33 @@ final class GameState {
|
||||
bustCount += 1
|
||||
}
|
||||
|
||||
// Create round result with all hand results and per-hand winnings
|
||||
// Create round result with all hand results, per-hand winnings, and side bets
|
||||
let allHandResults = playerHands.map { $0.result ?? .lose }
|
||||
|
||||
// Calculate individual side bet winnings
|
||||
let ppWinnings: Int
|
||||
if let ppResult = perfectPairsResult, perfectPairsBet > 0 {
|
||||
ppWinnings = ppResult.isWin ? perfectPairsBet * ppResult.payout : -perfectPairsBet
|
||||
} else {
|
||||
ppWinnings = 0
|
||||
}
|
||||
|
||||
let topWinnings: Int
|
||||
if let topResult = twentyOnePlusThreeResult, twentyOnePlusThreeBet > 0 {
|
||||
topWinnings = topResult.isWin ? twentyOnePlusThreeBet * topResult.payout : -twentyOnePlusThreeBet
|
||||
} else {
|
||||
topWinnings = 0
|
||||
}
|
||||
|
||||
lastRoundResult = RoundResult(
|
||||
handResults: allHandResults,
|
||||
handWinnings: perHandWinnings,
|
||||
insuranceResult: insResult,
|
||||
insuranceWinnings: insWinnings,
|
||||
perfectPairsResult: perfectPairsBet > 0 ? perfectPairsResult : nil,
|
||||
perfectPairsWinnings: ppWinnings,
|
||||
twentyOnePlusThreeResult: twentyOnePlusThreeBet > 0 ? twentyOnePlusThreeResult : nil,
|
||||
twentyOnePlusThreeWinnings: topWinnings,
|
||||
totalWinnings: roundWinnings,
|
||||
wasBlackjack: wasBlackjack
|
||||
)
|
||||
@ -782,6 +969,13 @@ final class GameState {
|
||||
// Reset bets
|
||||
currentBet = 0
|
||||
insuranceBet = 0
|
||||
perfectPairsBet = 0
|
||||
twentyOnePlusThreeBet = 0
|
||||
|
||||
// Reset side bet results
|
||||
perfectPairsResult = nil
|
||||
twentyOnePlusThreeResult = nil
|
||||
showSideBetToasts = false
|
||||
|
||||
// Reset UI state
|
||||
showResultBanner = false
|
||||
|
||||
@ -76,6 +76,17 @@ struct RoundResult: Equatable {
|
||||
let totalWinnings: Int
|
||||
let wasBlackjack: Bool
|
||||
|
||||
// MARK: - Side Bets
|
||||
|
||||
/// Perfect Pairs result (nil if no bet placed)
|
||||
let perfectPairsResult: PerfectPairsResult?
|
||||
/// Perfect Pairs winnings (positive if won, negative if lost)
|
||||
let perfectPairsWinnings: Int
|
||||
/// 21+3 result (nil if no bet placed)
|
||||
let twentyOnePlusThreeResult: TwentyOnePlusThreeResult?
|
||||
/// 21+3 winnings (positive if won, negative if lost)
|
||||
let twentyOnePlusThreeWinnings: Int
|
||||
|
||||
/// Convenience initializer without per-hand winnings (backwards compatibility)
|
||||
init(handResults: [HandResult], insuranceResult: HandResult?, totalWinnings: Int, wasBlackjack: Bool) {
|
||||
self.handResults = handResults
|
||||
@ -84,6 +95,10 @@ struct RoundResult: Equatable {
|
||||
self.insuranceWinnings = 0
|
||||
self.totalWinnings = totalWinnings
|
||||
self.wasBlackjack = wasBlackjack
|
||||
self.perfectPairsResult = nil
|
||||
self.perfectPairsWinnings = 0
|
||||
self.twentyOnePlusThreeResult = nil
|
||||
self.twentyOnePlusThreeWinnings = 0
|
||||
}
|
||||
|
||||
/// Full initializer with per-hand winnings
|
||||
@ -94,6 +109,35 @@ struct RoundResult: Equatable {
|
||||
self.insuranceWinnings = insuranceWinnings
|
||||
self.totalWinnings = totalWinnings
|
||||
self.wasBlackjack = wasBlackjack
|
||||
self.perfectPairsResult = nil
|
||||
self.perfectPairsWinnings = 0
|
||||
self.twentyOnePlusThreeResult = nil
|
||||
self.twentyOnePlusThreeWinnings = 0
|
||||
}
|
||||
|
||||
/// Full initializer with side bets
|
||||
init(
|
||||
handResults: [HandResult],
|
||||
handWinnings: [Int],
|
||||
insuranceResult: HandResult?,
|
||||
insuranceWinnings: Int,
|
||||
perfectPairsResult: PerfectPairsResult?,
|
||||
perfectPairsWinnings: Int,
|
||||
twentyOnePlusThreeResult: TwentyOnePlusThreeResult?,
|
||||
twentyOnePlusThreeWinnings: Int,
|
||||
totalWinnings: Int,
|
||||
wasBlackjack: Bool
|
||||
) {
|
||||
self.handResults = handResults
|
||||
self.handWinnings = handWinnings
|
||||
self.insuranceResult = insuranceResult
|
||||
self.insuranceWinnings = insuranceWinnings
|
||||
self.perfectPairsResult = perfectPairsResult
|
||||
self.perfectPairsWinnings = perfectPairsWinnings
|
||||
self.twentyOnePlusThreeResult = twentyOnePlusThreeResult
|
||||
self.twentyOnePlusThreeWinnings = twentyOnePlusThreeWinnings
|
||||
self.totalWinnings = totalWinnings
|
||||
self.wasBlackjack = wasBlackjack
|
||||
}
|
||||
|
||||
/// The main/best result for display purposes (first hand, or best if split)
|
||||
@ -112,6 +156,11 @@ struct RoundResult: Equatable {
|
||||
handResults.count > 1
|
||||
}
|
||||
|
||||
/// Whether this round had any side bets
|
||||
var hadSideBets: Bool {
|
||||
perfectPairsResult != nil || twentyOnePlusThreeResult != nil
|
||||
}
|
||||
|
||||
/// Legacy accessor for backwards compatibility
|
||||
var splitHandResult: HandResult? {
|
||||
handResults.count > 1 ? handResults[1] : nil
|
||||
|
||||
@ -132,6 +132,11 @@ final class GameSettings {
|
||||
/// Speed of card dealing (1.0 = normal)
|
||||
var dealingSpeed: Double = 1.0 { didSet { save() } }
|
||||
|
||||
// MARK: - Side Bets
|
||||
|
||||
/// Whether side bets (Perfect Pairs, 21+3) are enabled.
|
||||
var sideBetsEnabled: Bool = false { didSet { save() } }
|
||||
|
||||
// MARK: - Display Settings
|
||||
|
||||
/// Whether to show the cards remaining indicator.
|
||||
@ -232,6 +237,7 @@ final class GameSettings {
|
||||
self.noHoleCard = data.noHoleCard
|
||||
self.blackjackPayout = data.blackjackPayout
|
||||
self.insuranceAllowed = data.insuranceAllowed
|
||||
self.sideBetsEnabled = data.sideBetsEnabled
|
||||
self.showAnimations = data.showAnimations
|
||||
self.dealingSpeed = data.dealingSpeed
|
||||
self.showCardsRemaining = data.showCardsRemaining
|
||||
@ -257,6 +263,7 @@ final class GameSettings {
|
||||
noHoleCard: noHoleCard,
|
||||
blackjackPayout: blackjackPayout,
|
||||
insuranceAllowed: insuranceAllowed,
|
||||
sideBetsEnabled: sideBetsEnabled,
|
||||
showAnimations: showAnimations,
|
||||
dealingSpeed: dealingSpeed,
|
||||
showCardsRemaining: showCardsRemaining,
|
||||
@ -283,6 +290,7 @@ final class GameSettings {
|
||||
noHoleCard = false
|
||||
blackjackPayout = 1.5
|
||||
insuranceAllowed = true
|
||||
sideBetsEnabled = false
|
||||
showAnimations = true
|
||||
dealingSpeed = 1.0
|
||||
showCardsRemaining = true
|
||||
|
||||
229
Blackjack/Blackjack/Models/SideBet.swift
Normal file
229
Blackjack/Blackjack/Models/SideBet.swift
Normal file
@ -0,0 +1,229 @@
|
||||
//
|
||||
// SideBet.swift
|
||||
// Blackjack
|
||||
//
|
||||
// Side bet types and evaluation for Perfect Pairs and 21+3.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CasinoKit
|
||||
|
||||
// MARK: - Side Bet Types
|
||||
|
||||
/// Available side bet types in Blackjack.
|
||||
enum SideBetType: String, CaseIterable, Identifiable {
|
||||
case perfectPairs = "perfectPairs"
|
||||
case twentyOnePlusThree = "21+3"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .perfectPairs: return String(localized: "Perfect Pairs")
|
||||
case .twentyOnePlusThree: return String(localized: "21+3")
|
||||
}
|
||||
}
|
||||
|
||||
var shortName: String {
|
||||
switch self {
|
||||
case .perfectPairs: return "PP"
|
||||
case .twentyOnePlusThree: return "21+3"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .perfectPairs:
|
||||
return String(localized: "Bet on your first two cards forming a pair")
|
||||
case .twentyOnePlusThree:
|
||||
return String(localized: "Poker hand from your first two cards + dealer's upcard")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Perfect Pairs Results
|
||||
|
||||
/// Perfect Pairs side bet outcomes.
|
||||
enum PerfectPairsResult: CaseIterable {
|
||||
case perfectPair // Same rank AND same suit (e.g., K♠ K♠)
|
||||
case coloredPair // Same rank, same color, different suit (e.g., K♠ K♣)
|
||||
case mixedPair // Same rank, different color (e.g., K♠ K♥)
|
||||
case noPair // No pair
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .perfectPair: return String(localized: "Perfect Pair")
|
||||
case .coloredPair: return String(localized: "Colored Pair")
|
||||
case .mixedPair: return String(localized: "Mixed Pair")
|
||||
case .noPair: return String(localized: "No Pair")
|
||||
}
|
||||
}
|
||||
|
||||
/// Payout multiplier (e.g., 25 means 25:1)
|
||||
var payout: Int {
|
||||
switch self {
|
||||
case .perfectPair: return 25
|
||||
case .coloredPair: return 12
|
||||
case .mixedPair: return 6
|
||||
case .noPair: return 0
|
||||
}
|
||||
}
|
||||
|
||||
var isWin: Bool {
|
||||
self != .noPair
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 21+3 Results
|
||||
|
||||
/// 21+3 side bet outcomes (poker hands).
|
||||
enum TwentyOnePlusThreeResult: CaseIterable {
|
||||
case suitedTrips // Three of a kind, same suit (e.g., 7♠ 7♠ 7♠)
|
||||
case straightFlush // Straight + Flush (e.g., 5♠ 6♠ 7♠)
|
||||
case threeOfAKind // Three of a kind, different suits (e.g., 7♠ 7♥ 7♦)
|
||||
case straight // Three consecutive ranks (e.g., 5♠ 6♥ 7♦)
|
||||
case flush // Three cards of same suit (e.g., 2♠ 7♠ K♠)
|
||||
case nothing // No qualifying hand
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .suitedTrips: return String(localized: "Suited Trips")
|
||||
case .straightFlush: return String(localized: "Straight Flush")
|
||||
case .threeOfAKind: return String(localized: "Three of a Kind")
|
||||
case .straight: return String(localized: "Straight")
|
||||
case .flush: return String(localized: "Flush")
|
||||
case .nothing: return String(localized: "No Hand")
|
||||
}
|
||||
}
|
||||
|
||||
/// Payout multiplier (e.g., 100 means 100:1)
|
||||
var payout: Int {
|
||||
switch self {
|
||||
case .suitedTrips: return 100
|
||||
case .straightFlush: return 40
|
||||
case .threeOfAKind: return 30
|
||||
case .straight: return 10
|
||||
case .flush: return 5
|
||||
case .nothing: return 0
|
||||
}
|
||||
}
|
||||
|
||||
var isWin: Bool {
|
||||
self != .nothing
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Side Bet Evaluation
|
||||
|
||||
/// Evaluates side bet outcomes.
|
||||
enum SideBetEvaluator {
|
||||
|
||||
/// Evaluates Perfect Pairs bet from player's first two cards.
|
||||
static func evaluatePerfectPairs(card1: Card, card2: Card) -> PerfectPairsResult {
|
||||
// Must be same rank for any pair
|
||||
guard card1.rank == card2.rank else {
|
||||
return .noPair
|
||||
}
|
||||
|
||||
// Perfect Pair: same suit
|
||||
if card1.suit == card2.suit {
|
||||
return .perfectPair
|
||||
}
|
||||
|
||||
// Colored Pair: same color (both red or both black), different suit
|
||||
if card1.suit.isRed == card2.suit.isRed {
|
||||
return .coloredPair
|
||||
}
|
||||
|
||||
// Mixed Pair: different color
|
||||
return .mixedPair
|
||||
}
|
||||
|
||||
/// Evaluates 21+3 bet from player's first two cards + dealer's upcard.
|
||||
static func evaluateTwentyOnePlusThree(playerCard1: Card, playerCard2: Card, dealerUpcard: Card) -> TwentyOnePlusThreeResult {
|
||||
let cards = [playerCard1, playerCard2, dealerUpcard]
|
||||
let ranks = cards.map { $0.rank }
|
||||
let suits = cards.map { $0.suit }
|
||||
|
||||
let isFlush = suits.allSatisfy { $0 == suits[0] }
|
||||
let isStraight = checkStraight(ranks: ranks)
|
||||
let isThreeOfAKind = ranks.allSatisfy { $0 == ranks[0] }
|
||||
|
||||
// Check from highest payout to lowest
|
||||
if isThreeOfAKind && isFlush {
|
||||
return .suitedTrips
|
||||
}
|
||||
|
||||
if isStraight && isFlush {
|
||||
return .straightFlush
|
||||
}
|
||||
|
||||
if isThreeOfAKind {
|
||||
return .threeOfAKind
|
||||
}
|
||||
|
||||
if isStraight {
|
||||
return .straight
|
||||
}
|
||||
|
||||
if isFlush {
|
||||
return .flush
|
||||
}
|
||||
|
||||
return .nothing
|
||||
}
|
||||
|
||||
/// Checks if three ranks form a straight (consecutive values).
|
||||
/// Handles A-2-3 and Q-K-A straights.
|
||||
private static func checkStraight(ranks: [Rank]) -> Bool {
|
||||
// Convert to numeric values (Ace can be 1 or 14)
|
||||
let values = ranks.map { rankValue($0) }.sorted()
|
||||
|
||||
// Check normal straight (consecutive)
|
||||
if values[2] - values[1] == 1 && values[1] - values[0] == 1 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check A-2-3 (values would be [1, 2, 3] after converting Ace to 1)
|
||||
// But our rankValue gives Ace = 14, so check [2, 3, 14]
|
||||
let sortedRanks = ranks.sorted { rankValue($0) < rankValue($1) }
|
||||
let hasAce = sortedRanks.contains(.ace)
|
||||
let hasTwo = sortedRanks.contains(.two)
|
||||
let hasThree = sortedRanks.contains(.three)
|
||||
|
||||
if hasAce && hasTwo && hasThree {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Converts rank to numeric value for straight checking.
|
||||
private static func rankValue(_ rank: Rank) -> Int {
|
||||
switch rank {
|
||||
case .ace: return 14 // High ace for Q-K-A
|
||||
case .two: return 2
|
||||
case .three: return 3
|
||||
case .four: return 4
|
||||
case .five: return 5
|
||||
case .six: return 6
|
||||
case .seven: return 7
|
||||
case .eight: return 8
|
||||
case .nine: return 9
|
||||
case .ten: return 10
|
||||
case .jack: return 11
|
||||
case .queen: return 12
|
||||
case .king: return 13
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Suit Extension for Color
|
||||
|
||||
extension Suit {
|
||||
/// Whether this suit is red (hearts, diamonds).
|
||||
var isRed: Bool {
|
||||
self == .hearts || self == .diamonds
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -66,6 +66,7 @@ struct BlackjackSettingsData: PersistableGameData {
|
||||
noHoleCard: false,
|
||||
blackjackPayout: 1.5,
|
||||
insuranceAllowed: true,
|
||||
sideBetsEnabled: false,
|
||||
showAnimations: true,
|
||||
dealingSpeed: 1.0,
|
||||
showCardsRemaining: true,
|
||||
@ -89,6 +90,7 @@ struct BlackjackSettingsData: PersistableGameData {
|
||||
var noHoleCard: Bool
|
||||
var blackjackPayout: Double
|
||||
var insuranceAllowed: Bool
|
||||
var sideBetsEnabled: Bool
|
||||
var showAnimations: Bool
|
||||
var dealingSpeed: Double
|
||||
var showCardsRemaining: Bool
|
||||
|
||||
@ -125,7 +125,7 @@ struct GameTableView: View {
|
||||
// Table layout - fills available space
|
||||
BlackjackTableView(
|
||||
state: state,
|
||||
onPlaceBet: { placeBet(state: state) },
|
||||
selectedChip: selectedChip,
|
||||
fullScreenSize: fullScreenSize
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
@ -138,7 +138,7 @@ struct GameTableView: View {
|
||||
ChipSelectorView(
|
||||
selectedChip: $selectedChip,
|
||||
balance: state.balance,
|
||||
currentBet: state.currentBet,
|
||||
currentBet: state.minBetForChipSelector, // Use min bet so chips stay enabled if any bet type can accept more
|
||||
maxBet: state.settings.maxBet
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
@ -191,11 +191,6 @@ struct GameTableView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Betting
|
||||
|
||||
private func placeBet(state: GameState) {
|
||||
state.placeBet(amount: selectedChip.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
@ -94,6 +94,25 @@ struct ResultBannerView: View {
|
||||
amount: showInsAmount ? result.insuranceWinnings : nil
|
||||
)
|
||||
}
|
||||
|
||||
// Side bet results
|
||||
if let ppResult = result.perfectPairsResult {
|
||||
SideBetResultRow(
|
||||
label: String(localized: "Perfect Pairs"),
|
||||
resultText: ppResult.displayName,
|
||||
isWin: ppResult.isWin,
|
||||
amount: result.perfectPairsWinnings
|
||||
)
|
||||
}
|
||||
|
||||
if let topResult = result.twentyOnePlusThreeResult {
|
||||
SideBetResultRow(
|
||||
label: String(localized: "21+3"),
|
||||
resultText: topResult.displayName,
|
||||
isWin: topResult.isWin,
|
||||
amount: result.twentyOnePlusThreeWinnings
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(
|
||||
@ -245,6 +264,57 @@ struct ResultRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Side Bet Result Row
|
||||
|
||||
struct SideBetResultRow: View {
|
||||
let label: String
|
||||
let resultText: String
|
||||
let isWin: Bool
|
||||
let amount: Int
|
||||
|
||||
private var amountText: String {
|
||||
if amount > 0 {
|
||||
return "+$\(amount)"
|
||||
} else if amount < 0 {
|
||||
return "-$\(abs(amount))"
|
||||
} else {
|
||||
return "$0"
|
||||
}
|
||||
}
|
||||
|
||||
private var amountColor: Color {
|
||||
if amount > 0 { return .green }
|
||||
if amount < 0 { return .red }
|
||||
return .blue
|
||||
}
|
||||
|
||||
private var resultColor: Color {
|
||||
isWin ? .green : .red
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(amountText)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(amountColor)
|
||||
.frame(width: 70, alignment: .trailing)
|
||||
|
||||
Text(resultText)
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .bold))
|
||||
.foregroundStyle(resultColor)
|
||||
.frame(width: 100, alignment: .trailing)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Single Hand") {
|
||||
ResultBannerView(
|
||||
result: RoundResult(
|
||||
@ -296,3 +366,24 @@ struct ResultRow: View {
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("With Side Bets") {
|
||||
ResultBannerView(
|
||||
result: RoundResult(
|
||||
handResults: [.win],
|
||||
handWinnings: [100],
|
||||
insuranceResult: nil,
|
||||
insuranceWinnings: 0,
|
||||
perfectPairsResult: .coloredPair,
|
||||
perfectPairsWinnings: 300,
|
||||
twentyOnePlusThreeResult: .nothing,
|
||||
twentyOnePlusThreeWinnings: -25,
|
||||
totalWinnings: 375,
|
||||
wasBlackjack: false
|
||||
),
|
||||
currentBalance: 1375,
|
||||
minBet: 10,
|
||||
onNewRound: {},
|
||||
onPlayAgain: {}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -82,6 +82,43 @@ struct RulesHelpView: View {
|
||||
String(localized: "Surrender: Half bet returned")
|
||||
]
|
||||
),
|
||||
RulePage(
|
||||
title: String(localized: "Side Bets"),
|
||||
icon: "plus.circle.fill",
|
||||
content: [
|
||||
String(localized: "Optional bets placed before the deal."),
|
||||
String(localized: "Perfect Pairs: Bet on your first two cards being a pair."),
|
||||
String(localized: "21+3: Poker hand from your cards + dealer's upcard."),
|
||||
String(localized: "Side bets have higher house edge than main game."),
|
||||
String(localized: "Enable in Settings to play side bets.")
|
||||
]
|
||||
),
|
||||
RulePage(
|
||||
title: String(localized: "Perfect Pairs"),
|
||||
icon: "suit.heart.fill",
|
||||
content: [
|
||||
String(localized: "Bet on your first two cards forming a pair."),
|
||||
String(localized: "Mixed Pair (diff. color): 6:1"),
|
||||
String(localized: "Colored Pair (same color): 12:1"),
|
||||
String(localized: "Perfect Pair (same suit): 25:1"),
|
||||
String(localized: "Example: K♠ K♠ = Perfect Pair"),
|
||||
String(localized: "Example: K♠ K♣ = Colored Pair (both black)"),
|
||||
String(localized: "Example: K♠ K♥ = Mixed Pair (red/black)")
|
||||
]
|
||||
),
|
||||
RulePage(
|
||||
title: String(localized: "21+3"),
|
||||
icon: "dice.fill",
|
||||
content: [
|
||||
String(localized: "Forms a poker hand: your 2 cards + dealer upcard."),
|
||||
String(localized: "Flush (same suit): 5:1"),
|
||||
String(localized: "Straight (consecutive): 10:1"),
|
||||
String(localized: "Three of a Kind: 30:1"),
|
||||
String(localized: "Straight Flush: 40:1"),
|
||||
String(localized: "Suited Trips (same rank & suit): 100:1"),
|
||||
String(localized: "Aces can be high or low in straights (A-2-3 or Q-K-A).")
|
||||
]
|
||||
),
|
||||
RulePage(
|
||||
title: String(localized: "Vegas Strip"),
|
||||
icon: "sparkles",
|
||||
|
||||
@ -81,6 +81,16 @@ struct SettingsView: View {
|
||||
TableLimitsPicker(selection: $settings.tableLimits)
|
||||
}
|
||||
|
||||
// 3.5. Side Bets
|
||||
SheetSection(title: String(localized: "SIDE BETS"), icon: "dollarsign.arrow.trianglehead.counterclockwise.rotate.90") {
|
||||
SettingsToggle(
|
||||
title: String(localized: "Enable Side Bets"),
|
||||
subtitle: String(localized: "Perfect Pairs (25:1) and 21+3 (100:1)"),
|
||||
isOn: $settings.sideBetsEnabled,
|
||||
accentColor: accent
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Deck Settings
|
||||
SheetSection(title: String(localized: "DECK SETTINGS"), icon: "rectangle.portrait.on.rectangle.portrait") {
|
||||
DeckCountPicker(selection: $settings.deckCount)
|
||||
|
||||
@ -8,23 +8,93 @@
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// Main betting zone view - adapts layout based on whether side bets are enabled.
|
||||
/// Follows Baccarat's betting pattern exactly.
|
||||
struct BettingZoneView: View {
|
||||
let betAmount: Int
|
||||
let minBet: Int
|
||||
let maxBet: Int
|
||||
let onTap: () -> Void
|
||||
@Bindable var state: GameState
|
||||
let selectedChip: ChipDenomination
|
||||
|
||||
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
||||
@ScaledMetric(relativeTo: .caption) private var detailFontSize: CGFloat = Design.Size.handNumberFontSize
|
||||
@ScaledMetric(relativeTo: .body) private var chipSize: CGFloat = Design.Size.bettingChipSize
|
||||
@ScaledMetric(relativeTo: .body) private var zoneHeight: CGFloat = CasinoDesign.Size.bettingZoneHeight
|
||||
|
||||
private var isAtMax: Bool {
|
||||
betAmount >= maxBet
|
||||
// MARK: - Computed Properties (matches Baccarat's pattern)
|
||||
|
||||
/// Whether a bet can be added to main bet (matches Baccarat's canAddBet)
|
||||
private var canAddMainBet: Bool {
|
||||
state.canPlaceBet &&
|
||||
state.balance >= selectedChip.rawValue &&
|
||||
state.canAddToMainBet(amount: selectedChip.rawValue)
|
||||
}
|
||||
|
||||
/// Whether a bet can be added to Perfect Pairs
|
||||
private var canAddPerfectPairs: Bool {
|
||||
state.canPlaceBet &&
|
||||
state.balance >= selectedChip.rawValue &&
|
||||
state.canAddToSideBet(type: .perfectPairs, amount: selectedChip.rawValue)
|
||||
}
|
||||
|
||||
/// Whether a bet can be added to 21+3
|
||||
private var canAddTwentyOnePlusThree: Bool {
|
||||
state.canPlaceBet &&
|
||||
state.balance >= selectedChip.rawValue &&
|
||||
state.canAddToSideBet(type: .twentyOnePlusThree, amount: selectedChip.rawValue)
|
||||
}
|
||||
|
||||
private var isMainAtMax: Bool {
|
||||
state.currentBet >= state.settings.maxBet
|
||||
}
|
||||
|
||||
private var isPPAtMax: Bool {
|
||||
state.perfectPairsBet >= state.settings.maxBet
|
||||
}
|
||||
|
||||
private var is21PlusThreeAtMax: Bool {
|
||||
state.twentyOnePlusThreeBet >= state.settings.maxBet
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
if state.settings.sideBetsEnabled {
|
||||
// Horizontal layout: PP | Main Bet | 21+3
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
// Perfect Pairs
|
||||
SideBetZoneView(
|
||||
betType: .perfectPairs,
|
||||
betAmount: state.perfectPairsBet,
|
||||
isEnabled: canAddPerfectPairs,
|
||||
isAtMax: isPPAtMax,
|
||||
onTap: { state.placeSideBet(type: .perfectPairs, amount: selectedChip.rawValue) }
|
||||
)
|
||||
.frame(width: Design.Size.sideBetZoneWidth)
|
||||
|
||||
// Main bet (center, takes remaining space)
|
||||
mainBetZone
|
||||
|
||||
// 21+3
|
||||
SideBetZoneView(
|
||||
betType: .twentyOnePlusThree,
|
||||
betAmount: state.twentyOnePlusThreeBet,
|
||||
isEnabled: canAddTwentyOnePlusThree,
|
||||
isAtMax: is21PlusThreeAtMax,
|
||||
onTap: { state.placeSideBet(type: .twentyOnePlusThree, amount: selectedChip.rawValue) }
|
||||
)
|
||||
.frame(width: Design.Size.sideBetZoneWidth)
|
||||
}
|
||||
.frame(height: zoneHeight)
|
||||
} else {
|
||||
// Simple layout: just main bet
|
||||
mainBetZone
|
||||
.frame(height: zoneHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private var mainBetZone: some View {
|
||||
Button {
|
||||
if canAddMainBet {
|
||||
state.placeBet(amount: selectedChip.rawValue)
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
// Background
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||
@ -35,9 +105,9 @@ struct BettingZoneView: View {
|
||||
)
|
||||
|
||||
// Content
|
||||
if betAmount > 0 {
|
||||
if state.currentBet > 0 {
|
||||
// Show chip with amount (scaled)
|
||||
ChipOnTableView(amount: betAmount, showMax: isAtMax, size: chipSize)
|
||||
ChipOnTableView(amount: state.currentBet, showMax: isMainAtMax, size: chipSize)
|
||||
} else {
|
||||
// Empty state
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
@ -46,11 +116,11 @@ struct BettingZoneView: View {
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Text(String(localized: "Min: $\(minBet)"))
|
||||
Text(String(localized: "Min: $\(state.settings.minBet)"))
|
||||
.font(.system(size: detailFontSize, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||
|
||||
Text(String(localized: "Max: $\(maxBet.formatted())"))
|
||||
Text(String(localized: "Max: $\(state.settings.maxBet.formatted())"))
|
||||
.font(.system(size: detailFontSize, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||
}
|
||||
@ -58,50 +128,64 @@ struct BettingZoneView: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: zoneHeight)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(betAmount > 0 ? "$\(betAmount) bet" + (isAtMax ? ", maximum" : "") : "Place bet")
|
||||
.accessibilityLabel(state.currentBet > 0 ? "$\(state.currentBet) bet" + (isMainAtMax ? ", maximum" : "") : "Place bet")
|
||||
.accessibilityHint("Double tap to add chips")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Design Constants Extension
|
||||
|
||||
extension Design.Size {
|
||||
/// Width of side bet zones
|
||||
static let sideBetZoneWidth: CGFloat = 70
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Empty") {
|
||||
#Preview("Empty - No Side Bets") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
BettingZoneView(
|
||||
betAmount: 0,
|
||||
minBet: 10,
|
||||
maxBet: 1000,
|
||||
onTap: {}
|
||||
state: GameState(settings: GameSettings()),
|
||||
selectedChip: .hundred
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("With Bet") {
|
||||
#Preview("With Side Bets") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
BettingZoneView(
|
||||
betAmount: 250,
|
||||
minBet: 10,
|
||||
maxBet: 1000,
|
||||
onTap: {}
|
||||
state: {
|
||||
let settings = GameSettings()
|
||||
settings.sideBetsEnabled = true
|
||||
let state = GameState(settings: settings)
|
||||
state.placeBet(amount: 100)
|
||||
return state
|
||||
}(),
|
||||
selectedChip: .twentyFive
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Max Bet") {
|
||||
#Preview("All Bets Placed") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
BettingZoneView(
|
||||
betAmount: 1000,
|
||||
minBet: 10,
|
||||
maxBet: 1000,
|
||||
onTap: {}
|
||||
state: {
|
||||
let settings = GameSettings()
|
||||
settings.sideBetsEnabled = true
|
||||
let state = GameState(settings: settings)
|
||||
state.placeBet(amount: 250)
|
||||
state.placeSideBet(type: .perfectPairs, amount: 25)
|
||||
state.placeSideBet(type: .twentyOnePlusThree, amount: 50)
|
||||
return state
|
||||
}(),
|
||||
selectedChip: .hundred
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import CasinoKit
|
||||
|
||||
struct BlackjackTableView: View {
|
||||
@Bindable var state: GameState
|
||||
let onPlaceBet: () -> Void
|
||||
let selectedChip: ChipDenomination
|
||||
|
||||
/// Full screen size passed from parent (stable - measured from TableBackgroundView)
|
||||
let fullScreenSize: CGSize
|
||||
@ -99,14 +99,46 @@ struct BlackjackTableView: View {
|
||||
|
||||
// Player hands area - only show when there are cards dealt
|
||||
if state.playerHands.first?.cards.isEmpty == false {
|
||||
PlayerHandsView(
|
||||
hands: state.playerHands,
|
||||
activeHandIndex: state.activeHandIndex,
|
||||
isPlayerTurn: state.isPlayerTurn,
|
||||
showCardCount: showCardCount,
|
||||
cardWidth: cardWidth,
|
||||
cardSpacing: cardSpacing
|
||||
)
|
||||
ZStack {
|
||||
PlayerHandsView(
|
||||
hands: state.playerHands,
|
||||
activeHandIndex: state.activeHandIndex,
|
||||
isPlayerTurn: state.isPlayerTurn,
|
||||
showCardCount: showCardCount,
|
||||
cardWidth: cardWidth,
|
||||
cardSpacing: cardSpacing
|
||||
)
|
||||
|
||||
// Side bet toasts (positioned on left/right sides to not cover cards)
|
||||
if state.settings.sideBetsEnabled && state.showSideBetToasts {
|
||||
HStack {
|
||||
// PP on left
|
||||
if state.perfectPairsBet > 0, let ppResult = state.perfectPairsResult {
|
||||
SideBetToastView(
|
||||
title: "PP",
|
||||
result: ppResult.displayName,
|
||||
isWin: ppResult.isWin,
|
||||
amount: ppResult.isWin ? state.perfectPairsBet * ppResult.payout : -state.perfectPairsBet,
|
||||
showOnLeft: true
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// 21+3 on right
|
||||
if state.twentyOnePlusThreeBet > 0, let topResult = state.twentyOnePlusThreeResult {
|
||||
SideBetToastView(
|
||||
title: "21+3",
|
||||
result: topResult.displayName,
|
||||
isWin: topResult.isWin,
|
||||
amount: topResult.isWin ? state.twentyOnePlusThreeBet * topResult.payout : -state.twentyOnePlusThreeBet,
|
||||
showOnLeft: false
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.small)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 5)
|
||||
.transition(.opacity)
|
||||
.debugBorder(showDebugBorders, color: .green, label: "Player")
|
||||
@ -118,10 +150,8 @@ struct BlackjackTableView: View {
|
||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer2")
|
||||
|
||||
BettingZoneView(
|
||||
betAmount: state.currentBet,
|
||||
minBet: state.settings.minBet,
|
||||
maxBet: state.settings.maxBet,
|
||||
onTap: onPlaceBet
|
||||
state: state,
|
||||
selectedChip: selectedChip
|
||||
)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
.debugBorder(showDebugBorders, color: .blue, label: "BetZone")
|
||||
|
||||
@ -44,13 +44,11 @@ struct PlayerHandsView: View {
|
||||
.id(index)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.containerRelativeFrame(.horizontal) { length, _ in
|
||||
length // Ensures content fills container width for centering
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xxLarge) // More padding for scrolling
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.scrollBounceBehavior(.always) // Always allow bouncing for better scroll feel
|
||||
.defaultScrollAnchor(.center) // Center the content by default
|
||||
.onChange(of: activeHandIndex) { _, newIndex in
|
||||
scrollToHand(proxy: proxy, index: newIndex)
|
||||
}
|
||||
@ -66,6 +64,7 @@ struct PlayerHandsView: View {
|
||||
scrollToHand(proxy: proxy, index: activeHandIndex)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func scrollToHand(proxy: ScrollViewProxy, index: Int) {
|
||||
|
||||
141
Blackjack/Blackjack/Views/Table/SideBetToastView.swift
Normal file
141
Blackjack/Blackjack/Views/Table/SideBetToastView.swift
Normal file
@ -0,0 +1,141 @@
|
||||
//
|
||||
// SideBetToastView.swift
|
||||
// Blackjack
|
||||
//
|
||||
// Animated toast notifications for side bet results that appear after dealing.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// Toast notification for a single side bet result with built-in animation.
|
||||
struct SideBetToastView: View {
|
||||
let title: String
|
||||
let result: String
|
||||
let isWin: Bool
|
||||
let amount: Int
|
||||
let showOnLeft: Bool
|
||||
|
||||
@State private var isShowing = false
|
||||
|
||||
@ScaledMetric(relativeTo: .caption) private var titleFontSize: CGFloat = 10
|
||||
@ScaledMetric(relativeTo: .caption2) private var resultFontSize: CGFloat = 11
|
||||
@ScaledMetric(relativeTo: .caption2) private var amountFontSize: CGFloat = 13
|
||||
|
||||
private var backgroundColor: Color {
|
||||
isWin ? Color.green.opacity(Design.Opacity.heavy) : Color.red.opacity(Design.Opacity.heavy)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
isWin ? Color.green : Color.red
|
||||
}
|
||||
|
||||
private var amountText: String {
|
||||
if amount > 0 {
|
||||
return "+$\(amount)"
|
||||
} else {
|
||||
return "-$\(abs(amount))"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.xxSmall) {
|
||||
// Title (PP or 21+3)
|
||||
Text(title)
|
||||
.font(.system(size: titleFontSize, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
|
||||
// Result
|
||||
Text(result)
|
||||
.font(.system(size: resultFontSize, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
||||
|
||||
// Amount
|
||||
Text(amountText)
|
||||
.font(.system(size: amountFontSize, weight: .black, design: .rounded))
|
||||
.foregroundStyle(isWin ? .yellow : .white)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.small)
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(backgroundColor)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.strokeBorder(borderColor, lineWidth: Design.LineWidth.medium)
|
||||
)
|
||||
)
|
||||
.shadow(color: borderColor.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
|
||||
.scaleEffect(isShowing ? 1.0 : 0.5)
|
||||
.opacity(isShowing ? 1.0 : 0)
|
||||
.offset(x: isShowing ? 0 : (showOnLeft ? -50 : 50))
|
||||
.onAppear {
|
||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: 0.4).delay(showOnLeft ? 0 : Design.Animation.staggerDelay1)) {
|
||||
isShowing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Win Toast Left") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
HStack {
|
||||
SideBetToastView(
|
||||
title: "PP",
|
||||
result: "Perfect Pair",
|
||||
isWin: true,
|
||||
amount: 625,
|
||||
showOnLeft: true
|
||||
)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Lose Toast Right") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
HStack {
|
||||
Spacer()
|
||||
SideBetToastView(
|
||||
title: "21+3",
|
||||
result: "No Hand",
|
||||
isWin: false,
|
||||
amount: -25,
|
||||
showOnLeft: false
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Both Toasts") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
HStack {
|
||||
SideBetToastView(
|
||||
title: "PP",
|
||||
result: "Colored Pair",
|
||||
isWin: true,
|
||||
amount: 300,
|
||||
showOnLeft: true
|
||||
)
|
||||
Spacer()
|
||||
SideBetToastView(
|
||||
title: "21+3",
|
||||
result: "Flush",
|
||||
isWin: true,
|
||||
amount: 125,
|
||||
showOnLeft: false
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
163
Blackjack/Blackjack/Views/Table/SideBetZoneView.swift
Normal file
163
Blackjack/Blackjack/Views/Table/SideBetZoneView.swift
Normal file
@ -0,0 +1,163 @@
|
||||
//
|
||||
// SideBetZoneView.swift
|
||||
// Blackjack
|
||||
//
|
||||
// Side bet zone for Perfect Pairs and 21+3.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// A tappable zone for placing side bets.
|
||||
struct SideBetZoneView: View {
|
||||
let betType: SideBetType
|
||||
let betAmount: Int
|
||||
let isEnabled: Bool
|
||||
let isAtMax: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
@ScaledMetric(relativeTo: .caption) private var labelFontSize: CGFloat = 11
|
||||
@ScaledMetric(relativeTo: .caption2) private var payoutFontSize: CGFloat = 9
|
||||
|
||||
private var backgroundColor: Color {
|
||||
switch betType {
|
||||
case .perfectPairs:
|
||||
return Color.SideBet.perfectPairs
|
||||
case .twentyOnePlusThree:
|
||||
return Color.SideBet.twentyOnePlusThree
|
||||
}
|
||||
}
|
||||
|
||||
private var payoutText: String {
|
||||
switch betType {
|
||||
case .perfectPairs:
|
||||
return "25:1" // Best payout shown
|
||||
case .twentyOnePlusThree:
|
||||
return "100:1" // Best payout shown
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
if isEnabled { onTap() }
|
||||
} label: {
|
||||
ZStack {
|
||||
// Background
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(backgroundColor)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.strokeBorder(
|
||||
Color.white.opacity(Design.Opacity.hint),
|
||||
lineWidth: Design.LineWidth.thin
|
||||
)
|
||||
)
|
||||
|
||||
// Content
|
||||
VStack(spacing: Design.Spacing.xxSmall) {
|
||||
Text(betType.shortName)
|
||||
.font(.system(size: labelFontSize, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.yellow)
|
||||
|
||||
Text(payoutText)
|
||||
.font(.system(size: payoutFontSize, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
}
|
||||
|
||||
// Chip indicator - top right
|
||||
if betAmount > 0 {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
ChipBadgeView(amount: betAmount, isMax: isAtMax)
|
||||
.padding(Design.Spacing.xSmall)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.opacity(isEnabled ? 1.0 : Design.Opacity.medium)
|
||||
.accessibilityLabel("\(betType.displayName) bet, pays up to \(payoutText)")
|
||||
.accessibilityHint(betAmount > 0 ? "Current bet $\(betAmount)" : "Double tap to place bet")
|
||||
}
|
||||
}
|
||||
|
||||
/// Small chip badge for side bet indicators.
|
||||
struct ChipBadgeView: View {
|
||||
let amount: Int
|
||||
let isMax: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isMax ? Color.gray : Color.yellow)
|
||||
.frame(width: CasinoDesign.Size.chipBadge, height: CasinoDesign.Size.chipBadge)
|
||||
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(Design.Opacity.almostFull), lineWidth: Design.LineWidth.thin)
|
||||
.frame(width: CasinoDesign.Size.chipBadgeInner, height: CasinoDesign.Size.chipBadgeInner)
|
||||
|
||||
if isMax {
|
||||
Text("MAX")
|
||||
.font(.system(size: Design.BaseFontSize.xxSmall, weight: .black))
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
Text(formatCompact(amount))
|
||||
.font(.system(size: Design.BaseFontSize.small, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
}
|
||||
.shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusSmall, y: Design.Shadow.offsetSmall)
|
||||
}
|
||||
|
||||
private func formatCompact(_ value: Int) -> String {
|
||||
if value >= 1000 {
|
||||
return "\(value / 1000)K"
|
||||
}
|
||||
return "\(value)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Side Bet Colors
|
||||
|
||||
extension Color {
|
||||
enum SideBet {
|
||||
/// Perfect Pairs - purple theme
|
||||
static let perfectPairs = Color(red: 0.4, green: 0.2, blue: 0.5)
|
||||
/// 21+3 - teal/cyan theme
|
||||
static let twentyOnePlusThree = Color(red: 0.1, green: 0.4, blue: 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Perfect Pairs") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
SideBetZoneView(
|
||||
betType: .perfectPairs,
|
||||
betAmount: 0,
|
||||
isEnabled: true,
|
||||
isAtMax: false,
|
||||
onTap: {}
|
||||
)
|
||||
.frame(width: 80, height: 70)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("21+3 with Bet") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
SideBetZoneView(
|
||||
betType: .twentyOnePlusThree,
|
||||
betAmount: 25,
|
||||
isEnabled: true,
|
||||
isAtMax: false,
|
||||
onTap: {}
|
||||
)
|
||||
.frame(width: 80, height: 70)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user