535 lines
16 KiB
Swift
535 lines
16 KiB
Swift
//
|
|
// GameState.swift
|
|
// Baccarat
|
|
//
|
|
// Observable game state managing the flow of a baccarat game.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import CasinoKit
|
|
|
|
/// The current phase of a baccarat round.
|
|
enum GamePhase: Equatable {
|
|
case betting
|
|
case dealingInitial
|
|
case playerThirdCard
|
|
case bankerThirdCard
|
|
case showingResult
|
|
case roundComplete
|
|
}
|
|
|
|
/// Result of an individual bet after a round.
|
|
struct BetResult: Identifiable {
|
|
let id = UUID()
|
|
let type: BetType
|
|
let amount: Int
|
|
let payout: Int // Net winnings (positive) or loss (negative)
|
|
|
|
var isWin: Bool { payout > 0 }
|
|
var isLoss: Bool { payout < 0 }
|
|
var isPush: Bool { payout == 0 }
|
|
|
|
/// Display name for the bet type
|
|
var displayName: String {
|
|
switch type {
|
|
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 "Dragon P"
|
|
case .dragonBonusBanker: return "Dragon B"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Main observable game state class managing all game logic and UI state.
|
|
@Observable
|
|
@MainActor
|
|
final class GameState {
|
|
// MARK: - Settings
|
|
let settings: GameSettings
|
|
|
|
// MARK: - Sound
|
|
private let sound = SoundManager.shared
|
|
|
|
// MARK: - Game Engine
|
|
private(set) var engine: BaccaratEngine
|
|
|
|
// MARK: - Player State
|
|
var balance: Int = 10_000
|
|
var currentBets: [Bet] = []
|
|
|
|
// MARK: - Round State
|
|
var currentPhase: GamePhase = .betting
|
|
var lastResult: GameResult?
|
|
var lastWinnings: Int = 0
|
|
|
|
// MARK: - Bet Results
|
|
var playerHadPair: Bool = false
|
|
var bankerHadPair: Bool = false
|
|
var betResults: [BetResult] = []
|
|
|
|
// MARK: - Card Display State (for animations)
|
|
var visiblePlayerCards: [Card] = []
|
|
var visibleBankerCards: [Card] = []
|
|
var playerCardsFaceUp: [Bool] = []
|
|
var bankerCardsFaceUp: [Bool] = []
|
|
|
|
// MARK: - History
|
|
var roundHistory: [RoundResult] = []
|
|
|
|
// MARK: - Animation Flags
|
|
var isAnimating: Bool = false
|
|
var showResultBanner: Bool = false
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
var totalBetAmount: Int {
|
|
currentBets.reduce(0) { $0 + $1.amount }
|
|
}
|
|
|
|
var canPlaceBet: Bool {
|
|
currentPhase == .betting && !isAnimating
|
|
}
|
|
|
|
var canDeal: Bool {
|
|
currentPhase == .betting && hasMainBet && mainBetMeetsMinimum && !isAnimating
|
|
}
|
|
|
|
var playerHandValue: Int {
|
|
engine.playerHand.value
|
|
}
|
|
|
|
var bankerHandValue: Int {
|
|
engine.bankerHand.value
|
|
}
|
|
|
|
// Recent results for the road display (last 20)
|
|
var recentResults: [RoundResult] {
|
|
Array(roundHistory.suffix(20))
|
|
}
|
|
|
|
// MARK: - Animation Timing (based on settings)
|
|
|
|
private var dealDelay: Duration {
|
|
.milliseconds(Int(400 * settings.dealingSpeed))
|
|
}
|
|
|
|
private var flipDelay: Duration {
|
|
.milliseconds(Int(300 * settings.dealingSpeed))
|
|
}
|
|
|
|
private var shortDelay: Duration {
|
|
.milliseconds(Int(200 * settings.dealingSpeed))
|
|
}
|
|
|
|
private var resultDelay: Duration {
|
|
.milliseconds(Int(500 * settings.dealingSpeed))
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
|
|
/// Convenience initializer that constructs default settings on the main actor.
|
|
convenience init() {
|
|
self.init(settings: GameSettings())
|
|
}
|
|
|
|
init(settings: GameSettings) {
|
|
self.settings = settings
|
|
self.engine = BaccaratEngine(deckCount: settings.deckCount.rawValue)
|
|
self.balance = settings.startingBalance
|
|
}
|
|
|
|
// MARK: - Computed Properties for Bets
|
|
|
|
/// Returns the current main bet (Player or Banker), if any.
|
|
var mainBet: Bet? {
|
|
currentBets.first(where: { $0.type == .player || $0.type == .banker })
|
|
}
|
|
|
|
/// Returns the current tie bet, if any.
|
|
var tieBet: Bet? {
|
|
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
|
|
}
|
|
|
|
/// Maximum bet for the table.
|
|
var maxBet: Int {
|
|
settings.maxBet
|
|
}
|
|
|
|
/// Returns the current bet amount for a specific bet type.
|
|
func betAmount(for type: BetType) -> Int {
|
|
currentBets.first(where: { $0.type == type })?.amount ?? 0
|
|
}
|
|
|
|
/// Whether the main bet meets the minimum requirement.
|
|
var mainBetMeetsMinimum: Bool {
|
|
guard let bet = mainBet else { return false }
|
|
return bet.amount >= minBet
|
|
}
|
|
|
|
/// Whether a bet type can accept more chips (hasn't hit max).
|
|
func canAddToBet(type: BetType, amount: Int) -> Bool {
|
|
let currentAmount = betAmount(for: type)
|
|
return currentAmount + amount <= maxBet
|
|
}
|
|
|
|
// MARK: - Betting Actions
|
|
|
|
/// Places a bet of the specified amount on the given bet type.
|
|
/// 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 }
|
|
|
|
// Check if adding this bet would exceed max
|
|
let currentAmount = betAmount(for: type)
|
|
guard currentAmount + amount <= maxBet else { return }
|
|
|
|
// Handle mutually exclusive Player/Banker bets
|
|
if type == .player || type == .banker {
|
|
// Remove any existing opposite main bet and refund it
|
|
if let existingMainBet = mainBet, existingMainBet.type != type {
|
|
balance += existingMainBet.amount
|
|
currentBets.removeAll(where: { $0.type == existingMainBet.type })
|
|
}
|
|
}
|
|
|
|
// Check if there's already a bet of this type
|
|
if let index = currentBets.firstIndex(where: { $0.type == type }) {
|
|
// Add to existing bet
|
|
let existingBet = currentBets[index]
|
|
currentBets[index] = Bet(type: type, amount: existingBet.amount + amount)
|
|
} else {
|
|
currentBets.append(Bet(type: type, amount: amount))
|
|
}
|
|
|
|
balance -= amount
|
|
|
|
// Play chip placement sound
|
|
if settings.soundEnabled {
|
|
sound.play(.chipPlace)
|
|
}
|
|
if settings.hapticsEnabled {
|
|
sound.hapticLight()
|
|
}
|
|
}
|
|
|
|
/// Clears all current bets and returns the amounts to balance.
|
|
func clearBets() {
|
|
guard canPlaceBet, !currentBets.isEmpty else { return }
|
|
balance += totalBetAmount
|
|
currentBets = []
|
|
|
|
// Play clear bets sound
|
|
if settings.soundEnabled {
|
|
sound.play(.clearBets)
|
|
}
|
|
if settings.hapticsEnabled {
|
|
sound.hapticMedium()
|
|
}
|
|
}
|
|
|
|
/// Undoes the last bet placed.
|
|
func undoLastBet() {
|
|
guard canPlaceBet, let lastBet = currentBets.last else { return }
|
|
balance += lastBet.amount
|
|
currentBets.removeLast()
|
|
}
|
|
|
|
// MARK: - Game Flow
|
|
|
|
/// Starts a new round by dealing cards with animation.
|
|
func deal() async {
|
|
guard canDeal else { return }
|
|
|
|
isAnimating = true
|
|
engine.prepareNewRound()
|
|
|
|
// Clear visible cards and bet results
|
|
visiblePlayerCards = []
|
|
visibleBankerCards = []
|
|
playerCardsFaceUp = []
|
|
bankerCardsFaceUp = []
|
|
lastResult = nil
|
|
showResultBanner = false
|
|
playerHadPair = false
|
|
bankerHadPair = false
|
|
betResults = []
|
|
|
|
// Deal initial cards
|
|
currentPhase = .dealingInitial
|
|
let initialCards = engine.dealInitialCards()
|
|
|
|
// Check if animations are enabled
|
|
if settings.showAnimations {
|
|
// Animate dealing: P1, B1, P2, B2
|
|
for (index, card) in initialCards.enumerated() {
|
|
try? await Task.sleep(for: dealDelay)
|
|
|
|
// Play card deal sound
|
|
if settings.soundEnabled {
|
|
sound.play(.cardDeal)
|
|
}
|
|
|
|
if index % 2 == 0 {
|
|
visiblePlayerCards.append(card)
|
|
playerCardsFaceUp.append(false)
|
|
} else {
|
|
visibleBankerCards.append(card)
|
|
bankerCardsFaceUp.append(false)
|
|
}
|
|
}
|
|
|
|
// Brief pause then flip cards
|
|
try? await Task.sleep(for: flipDelay)
|
|
|
|
// Play card flip sound
|
|
if settings.soundEnabled {
|
|
sound.play(.cardFlip)
|
|
}
|
|
|
|
// Flip all cards face up
|
|
for i in 0..<playerCardsFaceUp.count {
|
|
playerCardsFaceUp[i] = true
|
|
}
|
|
for i in 0..<bankerCardsFaceUp.count {
|
|
bankerCardsFaceUp[i] = true
|
|
}
|
|
|
|
try? await Task.sleep(for: resultDelay)
|
|
} else {
|
|
// No animations - show all cards immediately
|
|
for (index, card) in initialCards.enumerated() {
|
|
if index % 2 == 0 {
|
|
visiblePlayerCards.append(card)
|
|
playerCardsFaceUp.append(true)
|
|
} else {
|
|
visibleBankerCards.append(card)
|
|
bankerCardsFaceUp.append(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for naturals
|
|
if engine.playerHand.isNatural || engine.bankerHand.isNatural {
|
|
await showResult()
|
|
return
|
|
}
|
|
|
|
// Player third card
|
|
currentPhase = .playerThirdCard
|
|
if let playerThird = engine.drawPlayerThirdCard() {
|
|
if settings.showAnimations {
|
|
try? await Task.sleep(for: dealDelay)
|
|
if settings.soundEnabled {
|
|
sound.play(.cardDeal)
|
|
}
|
|
visiblePlayerCards.append(playerThird)
|
|
playerCardsFaceUp.append(false)
|
|
try? await Task.sleep(for: shortDelay)
|
|
if settings.soundEnabled {
|
|
sound.play(.cardFlip)
|
|
}
|
|
playerCardsFaceUp[2] = true
|
|
try? await Task.sleep(for: flipDelay)
|
|
} else {
|
|
visiblePlayerCards.append(playerThird)
|
|
playerCardsFaceUp.append(true)
|
|
}
|
|
}
|
|
|
|
// Banker third card
|
|
currentPhase = .bankerThirdCard
|
|
if let bankerThird = engine.drawBankerThirdCard() {
|
|
if settings.showAnimations {
|
|
try? await Task.sleep(for: dealDelay)
|
|
if settings.soundEnabled {
|
|
sound.play(.cardDeal)
|
|
}
|
|
visibleBankerCards.append(bankerThird)
|
|
bankerCardsFaceUp.append(false)
|
|
try? await Task.sleep(for: shortDelay)
|
|
if settings.soundEnabled {
|
|
sound.play(.cardFlip)
|
|
}
|
|
bankerCardsFaceUp[2] = true
|
|
try? await Task.sleep(for: dealDelay)
|
|
} else {
|
|
visibleBankerCards.append(bankerThird)
|
|
bankerCardsFaceUp.append(true)
|
|
}
|
|
}
|
|
|
|
await showResult()
|
|
}
|
|
|
|
/// Shows the result and processes payouts.
|
|
private func showResult() async {
|
|
currentPhase = .showingResult
|
|
|
|
let result = engine.determineResult()
|
|
lastResult = result
|
|
|
|
// Record pair results for display
|
|
playerHadPair = engine.playerHasPair
|
|
bankerHadPair = engine.bankerHasPair
|
|
|
|
// Calculate and apply payouts, track individual results
|
|
var totalWinnings = 0
|
|
var results: [BetResult] = []
|
|
|
|
for bet in currentBets {
|
|
let payout = engine.calculatePayout(bet: bet, result: result)
|
|
totalWinnings += payout
|
|
|
|
// Track individual bet result
|
|
results.append(BetResult(type: bet.type, amount: bet.amount, payout: payout))
|
|
|
|
// Return original bet if not a loss
|
|
if payout >= 0 {
|
|
balance += bet.amount
|
|
}
|
|
|
|
// Add winnings
|
|
if payout > 0 {
|
|
balance += payout
|
|
}
|
|
}
|
|
|
|
betResults = results
|
|
lastWinnings = totalWinnings
|
|
|
|
// Play result sound
|
|
if totalWinnings > 0 {
|
|
// Determine if it's a big win (>= 5x any bet amount or >= 500)
|
|
let maxBetAmount = currentBets.map(\.amount).max() ?? 0
|
|
let isBigWin = totalWinnings >= maxBetAmount * 5 || totalWinnings >= 500
|
|
if settings.soundEnabled {
|
|
sound.play(isBigWin ? .bigWin : .win)
|
|
}
|
|
if settings.hapticsEnabled {
|
|
sound.hapticSuccess()
|
|
}
|
|
} else if totalWinnings < 0 {
|
|
if settings.soundEnabled {
|
|
sound.play(.lose)
|
|
}
|
|
if settings.hapticsEnabled {
|
|
sound.hapticError()
|
|
}
|
|
} else {
|
|
// Push (tie with main bet push)
|
|
if settings.soundEnabled {
|
|
sound.play(.push)
|
|
}
|
|
if settings.hapticsEnabled {
|
|
sound.hapticMedium()
|
|
}
|
|
}
|
|
|
|
// Record result in history
|
|
roundHistory.append(RoundResult(
|
|
result: result,
|
|
playerValue: playerHandValue,
|
|
bankerValue: bankerHandValue,
|
|
playerPair: playerHadPair,
|
|
bankerPair: bankerHadPair
|
|
))
|
|
|
|
// Show result banner - stays until user taps New Round
|
|
showResultBanner = true
|
|
currentPhase = .roundComplete
|
|
isAnimating = false
|
|
}
|
|
|
|
/// Prepares for a new round.
|
|
func newRound() {
|
|
guard currentPhase == .roundComplete else { return }
|
|
|
|
// Play new round sound
|
|
if settings.soundEnabled {
|
|
sound.play(.newRound)
|
|
}
|
|
|
|
// Dismiss result banner
|
|
showResultBanner = false
|
|
|
|
currentBets = []
|
|
visiblePlayerCards = []
|
|
visibleBankerCards = []
|
|
playerCardsFaceUp = []
|
|
bankerCardsFaceUp = []
|
|
lastResult = nil
|
|
lastWinnings = 0
|
|
playerHadPair = false
|
|
bankerHadPair = false
|
|
betResults = []
|
|
currentPhase = .betting
|
|
}
|
|
|
|
/// Rebet the same amounts as last round.
|
|
func rebet() {
|
|
guard currentPhase == .roundComplete || (currentPhase == .betting && currentBets.isEmpty) else { return }
|
|
|
|
if currentPhase == .roundComplete {
|
|
newRound()
|
|
}
|
|
}
|
|
|
|
/// Resets the game to initial state with current settings.
|
|
func resetGame() {
|
|
engine = BaccaratEngine(deckCount: settings.deckCount.rawValue)
|
|
balance = settings.startingBalance
|
|
currentBets = []
|
|
currentPhase = .betting
|
|
lastResult = nil
|
|
lastWinnings = 0
|
|
visiblePlayerCards = []
|
|
visibleBankerCards = []
|
|
playerCardsFaceUp = []
|
|
bankerCardsFaceUp = []
|
|
roundHistory = []
|
|
isAnimating = false
|
|
showResultBanner = false
|
|
playerHadPair = false
|
|
bankerHadPair = false
|
|
betResults = []
|
|
|
|
// Play new game sound
|
|
if settings.soundEnabled {
|
|
sound.play(.newRound)
|
|
}
|
|
if settings.hapticsEnabled {
|
|
sound.hapticMedium()
|
|
}
|
|
}
|
|
|
|
/// Applies new settings (call after settings change).
|
|
func applySettings() {
|
|
resetGame()
|
|
}
|
|
}
|