From a898da7664eedf3012158ef952091346b46502c6 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 16 Dec 2025 17:26:42 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Baccarat.xcodeproj/project.pbxproj | 15 + Baccarat/Agents.md | 81 +++ Baccarat/ContentView.swift | 8 +- Baccarat/Engine/BaccaratEngine.swift | 196 +++++++ Baccarat/Engine/GameState.swift | 392 +++++++++++++ Baccarat/Models/BetType.swift | 58 ++ Baccarat/Models/Card.swift | 83 +++ Baccarat/Models/GameResult.swift | 67 +++ Baccarat/Models/GameSettings.swift | 214 ++++++++ Baccarat/Models/Hand.swift | 47 ++ Baccarat/Models/Shoe.swift | 70 +++ Baccarat/Views/CardView.swift | 254 +++++++++ Baccarat/Views/ChipSelectorView.swift | 79 +++ Baccarat/Views/ChipView.swift | 291 ++++++++++ Baccarat/Views/GameTableView.swift | 609 +++++++++++++++++++++ Baccarat/Views/MiniBaccaratTableView.swift | 475 ++++++++++++++++ Baccarat/Views/ResultBannerView.swift | 163 ++++++ Baccarat/Views/RoadMapView.swift | 92 ++++ Baccarat/Views/SettingsView.swift | 384 +++++++++++++ 19 files changed, 3571 insertions(+), 7 deletions(-) create mode 100644 Baccarat/Agents.md create mode 100644 Baccarat/Engine/BaccaratEngine.swift create mode 100644 Baccarat/Engine/GameState.swift create mode 100644 Baccarat/Models/BetType.swift create mode 100644 Baccarat/Models/Card.swift create mode 100644 Baccarat/Models/GameResult.swift create mode 100644 Baccarat/Models/GameSettings.swift create mode 100644 Baccarat/Models/Hand.swift create mode 100644 Baccarat/Models/Shoe.swift create mode 100644 Baccarat/Views/CardView.swift create mode 100644 Baccarat/Views/ChipSelectorView.swift create mode 100644 Baccarat/Views/ChipView.swift create mode 100644 Baccarat/Views/GameTableView.swift create mode 100644 Baccarat/Views/MiniBaccaratTableView.swift create mode 100644 Baccarat/Views/ResultBannerView.swift create mode 100644 Baccarat/Views/RoadMapView.swift create mode 100644 Baccarat/Views/SettingsView.swift diff --git a/Baccarat.xcodeproj/project.pbxproj b/Baccarat.xcodeproj/project.pbxproj index 5072644..ef7cf50 100644 --- a/Baccarat.xcodeproj/project.pbxproj +++ b/Baccarat.xcodeproj/project.pbxproj @@ -29,9 +29,22 @@ EAD890CE2EF1E9CF006DBA80 /* BaccaratUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BaccaratUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + EAD891062EF1F51E006DBA80 /* Exceptions for "Baccarat" folder in "Baccarat" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Agents.md, + ); + target = EAD890B62EF1E9CE006DBA80 /* Baccarat */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ EAD890B92EF1E9CE006DBA80 /* Baccarat */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + EAD891062EF1F51E006DBA80 /* Exceptions for "Baccarat" folder in "Baccarat" target */, + ); path = Baccarat; sourceTree = ""; }; @@ -195,6 +208,8 @@ ); mainGroup = EAD890AE2EF1E9CE006DBA80; minimizedProjectReferenceProxies = 1; + packageReferences = ( + ); preferredProjectObjectVersion = 77; productRefGroup = EAD890B82EF1E9CE006DBA80 /* Products */; projectDirPath = ""; diff --git a/Baccarat/Agents.md b/Baccarat/Agents.md new file mode 100644 index 0000000..193adc8 --- /dev/null +++ b/Baccarat/Agents.md @@ -0,0 +1,81 @@ +# Agent guide for Swift and SwiftUI + +This repository contains an Xcode project written with Swift and SwiftUI. Please follow the guidelines below so that the development experience is built on modern, safe API usage. + + +## Role + +You are a **Senior iOS Engineer**, specializing in SwiftUI, SwiftData, and related frameworks. Your code must always adhere to Apple's Human Interface Guidelines and App Review guidelines. + + +## Core instructions + +- Target iOS 26.0 or later. (Yes, it definitely exists.) +- Swift 6.2 or later, using modern Swift concurrency. +- SwiftUI backed up by `@Observable` classes for shared data. +- Do not introduce third-party frameworks without asking first. +- Avoid UIKit unless requested. + + +## Swift instructions + +- Always mark `@Observable` classes with `@MainActor`. +- Assume strict Swift concurrency rules are being applied. +- Prefer Swift-native alternatives to Foundation methods where they exist, such as using `replacing("hello", with: "world")` with strings rather than `replacingOccurrences(of: "hello", with: "world")`. +- Prefer modern Foundation API, for example `URL.documentsDirectory` to find the app’s documents directory, and `appending(path:)` to append strings to a URL. +- Never use C-style number formatting such as `Text(String(format: "%.2f", abs(myNumber)))`; always use `Text(abs(change), format: .number.precision(.fractionLength(2)))` instead. +- Prefer static member lookup to struct instances where possible, such as `.circle` rather than `Circle()`, and `.borderedProminent` rather than `BorderedProminentButtonStyle()`. +- Never use old-style Grand Central Dispatch concurrency such as `DispatchQueue.main.async()`. If behavior like this is needed, always use modern Swift concurrency. +- Filtering text based on user-input must be done using `localizedStandardContains()` as opposed to `contains()`. +- Avoid force unwraps and force `try` unless it is unrecoverable. + + +## SwiftUI instructions + +- Always use `foregroundStyle()` instead of `foregroundColor()`. +- Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`. +- Always use the `Tab` API instead of `tabItem()`. +- Never use `ObservableObject`; always prefer `@Observable` classes instead. +- Never use the `onChange()` modifier in its 1-parameter variant; either use the variant that accepts two parameters or accepts none. +- Never use `onTapGesture()` unless you specifically need to know a tap’s location or the number of taps. All other usages should use `Button`. +- Never use `Task.sleep(nanoseconds:)`; always use `Task.sleep(for:)` instead. +- Never use `UIScreen.main.bounds` to read the size of the available space. +- Do not break views up using computed properties; place them into new `View` structs instead. +- Do not force specific font sizes; prefer using Dynamic Type instead. +- Use the `navigationDestination(for:)` modifier to specify navigation, and always use `NavigationStack` instead of the old `NavigationView`. +- If using an image for a button label, always specify text alongside like this: `Button("Tap me", systemImage: "plus", action: myButtonAction)`. +- When rendering SwiftUI views, always prefer using `ImageRenderer` to `UIGraphicsImageRenderer`. +- Don’t apply the `fontWeight()` modifier unless there is good reason. If you want to make some text bold, always use `bold()` instead of `fontWeight(.bold)`. +- Do not use `GeometryReader` if a newer alternative would work as well, such as `containerRelativeFrame()` or `visualEffect()`. +- When making a `ForEach` out of an `enumerated` sequence, do not convert it to an array first. So, prefer `ForEach(x.enumerated(), id: \.element.id)` instead of `ForEach(Array(x.enumerated()), id: \.element.id)`. +- When hiding scroll view indicators, use the `.scrollIndicators(.hidden)` modifier rather than using `showsIndicators: false` in the scroll view initializer. +- Place view logic into view models or similar, so it can be tested. +- Avoid `AnyView` unless it is absolutely required. +- Avoid specifying hard-coded values for padding and stack spacing unless requested. +- Avoid using UIKit colors in SwiftUI code. + + +## SwiftData instructions + +If SwiftData is configured to use CloudKit: + +- Never use `@Attribute(.unique)`. +- Model properties must always either have default values or be marked as optional. +- All relationships must be marked optional. + + +## Project structure + +- Use a consistent project structure, with folder layout determined by app features. +- Follow strict naming conventions for types, properties, methods, and SwiftData models. +- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file. +- Write unit tests for core application logic. +- Only write UI tests if unit tests are not possible. +- Add code comments and documentation comments as needed. +- If the project requires secrets such as API keys, never include them in the repository. + + +## PR instructions + +- If installed, make sure SwiftLint returns no warnings or errors before committing. + diff --git a/Baccarat/ContentView.swift b/Baccarat/ContentView.swift index 99c4fb3..bf93be5 100644 --- a/Baccarat/ContentView.swift +++ b/Baccarat/ContentView.swift @@ -9,13 +9,7 @@ import SwiftUI struct ContentView: View { var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() + GameTableView() } } diff --git a/Baccarat/Engine/BaccaratEngine.swift b/Baccarat/Engine/BaccaratEngine.swift new file mode 100644 index 0000000..672a57e --- /dev/null +++ b/Baccarat/Engine/BaccaratEngine.swift @@ -0,0 +1,196 @@ +// +// BaccaratEngine.swift +// Baccarat +// +// Core game engine implementing all baccarat rules including third card logic. +// + +import Foundation + +/// The baccarat game engine implementing Punto Banco rules. +struct BaccaratEngine { + private(set) var shoe: Shoe + private(set) var playerHand: Hand + private(set) var bankerHand: Hand + + /// Creates a new engine with a fresh shoe. + init(deckCount: Int = 8) { + shoe = Shoe(deckCount: deckCount) + playerHand = Hand() + bankerHand = Hand() + + // Burn first card according to casino rules + shoe.burn(1) + } + + /// Clears hands and checks if shoe needs reshuffling. + mutating func prepareNewRound() { + playerHand.clear() + bankerHand.clear() + + if shoe.needsReshuffle { + shoe.shuffle() + shoe.burn(1) + } + } + + /// Deals the initial two cards to both Player and Banker. + /// Returns the cards in order they were dealt: P1, B1, P2, B2 + mutating func dealInitialCards() -> [Card] { + var dealtCards: [Card] = [] + + // Deal alternating: Player, Banker, Player, Banker + if let p1 = shoe.deal() { + playerHand.addCard(p1) + dealtCards.append(p1) + } + if let b1 = shoe.deal() { + bankerHand.addCard(b1) + dealtCards.append(b1) + } + if let p2 = shoe.deal() { + playerHand.addCard(p2) + dealtCards.append(p2) + } + if let b2 = shoe.deal() { + bankerHand.addCard(b2) + dealtCards.append(b2) + } + + return dealtCards + } + + /// Determines if the Player should draw a third card. + /// Player draws on 0-5, stands on 6-7. Natural (8-9) prevents drawing. + func shouldPlayerDraw() -> Bool { + // Check for naturals first + if playerHand.isNatural || bankerHand.isNatural { + return false + } + + return playerHand.value <= 5 + } + + /// Draws a third card for the Player if rules allow. + /// - Returns: The drawn card, or nil if Player stands. + mutating func drawPlayerThirdCard() -> Card? { + guard shouldPlayerDraw() else { return nil } + + if let card = shoe.deal() { + playerHand.addCard(card) + return card + } + return nil + } + + /// Determines if the Banker should draw a third card. + /// This follows the complex Punto Banco third card rules. + func shouldBankerDraw() -> Bool { + // Check for naturals first + if playerHand.isNatural || bankerHand.isNatural { + return false + } + + let bankerValue = bankerHand.value + + // If Player didn't draw (stood on 6-7), Banker uses simple rules + if playerHand.cardCount == 2 { + return bankerValue <= 5 + } + + // Player drew a third card - apply complex Banker rules + guard let playerThirdCard = playerHand.thirdCard else { + return bankerValue <= 5 + } + + let p3Value = playerThirdCard.baccaratValue + + // Banker third card rules based on Banker's total and Player's third card + switch bankerValue { + case 0, 1, 2: + // Banker always draws on 0-2 + return true + + case 3: + // Banker draws unless Player's third card was 8 + return p3Value != 8 + + case 4: + // Banker draws if Player's third card was 2-7 + return (2...7).contains(p3Value) + + case 5: + // Banker draws if Player's third card was 4-7 + return (4...7).contains(p3Value) + + case 6: + // Banker draws if Player's third card was 6-7 + return (6...7).contains(p3Value) + + case 7: + // Banker stands on 7 + return false + + default: + // 8-9 are naturals, shouldn't reach here + return false + } + } + + /// Draws a third card for the Banker if rules allow. + /// - Returns: The drawn card, or nil if Banker stands. + mutating func drawBankerThirdCard() -> Card? { + guard shouldBankerDraw() else { return nil } + + if let card = shoe.deal() { + bankerHand.addCard(card) + return card + } + return nil + } + + /// Determines the winner of the current round. + func determineResult() -> GameResult { + let playerValue = playerHand.value + let bankerValue = bankerHand.value + + if playerValue > bankerValue { + return .playerWins + } else if bankerValue > playerValue { + return .bankerWins + } else { + return .tie + } + } + + /// 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 { + if result.isPush(for: bet.type) { + // Push - bet is returned + return 0 + } + + if result.isWinningBet(bet.type) { + // Win - return winnings based on payout multiplier + return Int(Double(bet.amount) * bet.type.payoutMultiplier) + } else { + // Loss - lose the bet amount + return -bet.amount + } + } + + /// Plays a complete round automatically and returns the result. + /// Used for simulation/testing purposes. + mutating func playRound() -> GameResult { + prepareNewRound() + _ = dealInitialCards() + _ = drawPlayerThirdCard() + _ = drawBankerThirdCard() + return determineResult() + } +} + diff --git a/Baccarat/Engine/GameState.swift b/Baccarat/Engine/GameState.swift new file mode 100644 index 0000000..e95d787 --- /dev/null +++ b/Baccarat/Engine/GameState.swift @@ -0,0 +1,392 @@ +// +// GameState.swift +// Baccarat +// +// Observable game state managing the flow of a baccarat game. +// + +import Foundation +import SwiftUI + +/// The current phase of a baccarat round. +enum GamePhase: Equatable { + case betting + case dealingInitial + case playerThirdCard + case bankerThirdCard + case showingResult + case roundComplete +} + +/// Main observable game state class managing all game logic and UI state. +@Observable +@MainActor +final class GameState { + // MARK: - Settings + let settings: GameSettings + + // 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: - 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 }) + } + + /// Whether the player has placed a main bet (required to deal). + var hasMainBet: Bool { + mainBet != nil + } + + /// 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. Tie can be added as a side bet. + /// 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 + } + + /// Clears all current bets and returns the amounts to balance. + func clearBets() { + guard canPlaceBet else { return } + balance += totalBetAmount + currentBets = [] + } + + /// Removes 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 + visiblePlayerCards = [] + visibleBankerCards = [] + playerCardsFaceUp = [] + bankerCardsFaceUp = [] + lastResult = nil + showResultBanner = false + + // 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) + + 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) + + // Flip all cards face up + for i in 0..= 0 { + balance += bet.amount + } + + // Add winnings + if payout > 0 { + balance += payout + } + } + + lastWinnings = totalWinnings + + // Record result in history + roundHistory.append(RoundResult( + result: result, + playerValue: playerHandValue, + bankerValue: bankerHandValue + )) + + // Show result banner + showResultBanner = true + + try? await Task.sleep(for: .seconds(2)) + + currentPhase = .roundComplete + showResultBanner = false + isAnimating = false + } + + /// Prepares for a new round. + func newRound() { + guard currentPhase == .roundComplete else { return } + + currentBets = [] + visiblePlayerCards = [] + visibleBankerCards = [] + playerCardsFaceUp = [] + bankerCardsFaceUp = [] + lastResult = nil + lastWinnings = 0 + 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 + } + + /// Applies new settings (call after settings change). + func applySettings() { + resetGame() + } +} + diff --git a/Baccarat/Models/BetType.swift b/Baccarat/Models/BetType.swift new file mode 100644 index 0000000..f17659c --- /dev/null +++ b/Baccarat/Models/BetType.swift @@ -0,0 +1,58 @@ +// +// BetType.swift +// Baccarat +// +// Bet types and payout rates for baccarat. +// + +import Foundation +import SwiftUI + +/// The types of bets available in baccarat. +enum BetType: String, CaseIterable, Identifiable { + case player = "Player" + case banker = "Banker" + case tie = "Tie" + + 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. + var payoutMultiplier: Double { + switch self { + case .player: return 1.0 + case .banker: return 0.95 // 5% commission + case .tie: return 8.0 + } + } + + /// Display name with payout info. + var displayWithPayout: String { + switch self { + case .player: return "Player (1:1)" + case .banker: return "Banker (0.95:1)" + case .tie: return "Tie (8:1)" + } + } + + /// The color associated with this bet type. + var color: Color { + switch self { + case .player: return .blue + case .banker: return .red + case .tie: return .green + } + } +} + +/// Represents a bet placed by the user. +struct Bet: Identifiable, Equatable { + let id = UUID() + let type: BetType + let amount: Int + + static func == (lhs: Bet, rhs: Bet) -> Bool { + lhs.id == rhs.id + } +} + diff --git a/Baccarat/Models/Card.swift b/Baccarat/Models/Card.swift new file mode 100644 index 0000000..1708dc4 --- /dev/null +++ b/Baccarat/Models/Card.swift @@ -0,0 +1,83 @@ +// +// Card.swift +// Baccarat +// +// A playing card model with suit, rank, and baccarat value calculation. +// + +import Foundation + +/// Represents the four suits in a standard deck of cards. +enum Suit: String, CaseIterable, Identifiable { + case hearts = "♥" + case diamonds = "♦" + case clubs = "♣" + case spades = "♠" + + var id: String { rawValue } + + /// Whether this suit is red (hearts or diamonds). + var isRed: Bool { + self == .hearts || self == .diamonds + } +} + +/// Represents the rank of a card from Ace through King. +enum Rank: Int, CaseIterable, Identifiable { + case ace = 1 + case two = 2 + case three = 3 + case four = 4 + case five = 5 + case six = 6 + case seven = 7 + case eight = 8 + case nine = 9 + case ten = 10 + case jack = 11 + case queen = 12 + case king = 13 + + var id: Int { rawValue } + + /// The display symbol for this rank. + var symbol: String { + switch self { + case .ace: return "A" + case .jack: return "J" + case .queen: return "Q" + case .king: return "K" + default: return String(rawValue) + } + } + + /// The baccarat point value for this rank. + /// Aces = 1, 2-9 = face value, 10/J/Q/K = 0 + var baccaratValue: Int { + switch self { + case .ace: return 1 + case .two, .three, .four, .five, .six, .seven, .eight, .nine: + return rawValue + case .ten, .jack, .queen, .king: + return 0 + } + } +} + +/// Represents a single playing card with a suit and rank. +struct Card: Identifiable, Equatable { + let id = UUID() + let suit: Suit + let rank: Rank + + /// The baccarat point value of this card. + var baccaratValue: Int { + rank.baccaratValue + } + + /// Display string showing rank and suit together. + var display: String { + "\(rank.symbol)\(suit.rawValue)" + } +} + diff --git a/Baccarat/Models/GameResult.swift b/Baccarat/Models/GameResult.swift new file mode 100644 index 0000000..687ed88 --- /dev/null +++ b/Baccarat/Models/GameResult.swift @@ -0,0 +1,67 @@ +// +// GameResult.swift +// Baccarat +// +// Game outcome types and result information. +// + +import Foundation +import SwiftUI + +/// The possible outcomes of a baccarat round. +enum GameResult: Equatable { + case playerWins + case bankerWins + case tie + + /// Display text for the result. + var displayText: String { + switch self { + case .playerWins: return "Player Wins!" + case .bankerWins: return "Banker Wins!" + case .tie: return "Tie!" + } + } + + /// The color associated with this result. + var color: Color { + switch self { + case .playerWins: return .blue + case .bankerWins: return .red + case .tie: return .green + } + } + + /// Whether the given bet type wins with this result. + func isWinningBet(_ betType: BetType) -> Bool { + switch (self, betType) { + case (.playerWins, .player): return true + case (.bankerWins, .banker): return true + case (.tie, .tie): return true + default: return false + } + } + + /// Returns true if this result is a push (tie) for non-tie bets. + /// In baccarat, if the result is a tie, Player and Banker bets push (returned). + func isPush(for betType: BetType) -> Bool { + self == .tie && betType != .tie + } +} + +/// Record of a completed round for history tracking. +struct RoundResult: Identifiable { + let id = UUID() + let result: GameResult + let playerValue: Int + let bankerValue: Int + let timestamp: Date + + init(result: GameResult, playerValue: Int, bankerValue: Int) { + self.result = result + self.playerValue = playerValue + self.bankerValue = bankerValue + self.timestamp = .now + } +} + diff --git a/Baccarat/Models/GameSettings.swift b/Baccarat/Models/GameSettings.swift new file mode 100644 index 0000000..a19b02e --- /dev/null +++ b/Baccarat/Models/GameSettings.swift @@ -0,0 +1,214 @@ +// +// GameSettings.swift +// Baccarat +// +// User-configurable game settings. +// + +import Foundation +import SwiftUI + +/// The number of decks available for the shoe. +enum DeckCount: Int, CaseIterable, Identifiable { + case one = 1 + case six = 6 + case eight = 8 + + var id: Int { rawValue } + + var displayName: String { + switch self { + case .one: return "1 Deck" + case .six: return "6 Decks" + case .eight: return "8 Decks (Standard)" + } + } + + var description: String { + switch self { + case .one: return "Rare, for private games" + case .six: return "Common in mini baccarat" + case .eight: return "Casino standard" + } + } +} + +/// Preset table limits for betting. +enum TableLimits: String, CaseIterable, Identifiable { + case casual = "casual" + case low = "low" + case medium = "medium" + case high = "high" + case vip = "vip" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .casual: return "Casual" + case .low: return "Low Stakes" + case .medium: return "Medium Stakes" + case .high: return "High Stakes" + case .vip: return "VIP" + } + } + + var minBet: Int { + switch self { + case .casual: return 5 + case .low: return 10 + case .medium: return 25 + case .high: return 100 + case .vip: return 500 + } + } + + var maxBet: Int { + switch self { + case .casual: return 500 + case .low: return 1_000 + case .medium: return 5_000 + case .high: return 10_000 + case .vip: return 50_000 + } + } + + var description: String { + "$\(minBet) - $\(maxBet.formatted())" + } + + var detailedDescription: String { + switch self { + case .casual: return "Perfect for learning" + case .low: return "Standard mini baccarat" + case .medium: return "Regular casino table" + case .high: return "High roller table" + case .vip: return "Exclusive VIP room" + } + } +} + +/// Observable settings class for game configuration. +@Observable +@MainActor +final class GameSettings { + // MARK: - Deck Settings + + /// Number of decks in the shoe. + var deckCount: DeckCount = .eight + + // MARK: - Betting Limits + + /// The table limits preset. + var tableLimits: TableLimits = .low + + /// Minimum bet amount. + var minBet: Int { + tableLimits.minBet + } + + /// Maximum bet amount per betting spot. + var maxBet: Int { + tableLimits.maxBet + } + + // MARK: - Starting Balance + + /// The starting balance for new games. + var startingBalance: Int = 10_000 + + // MARK: - Animation Settings + + /// Whether to show dealing animations. + var showAnimations: Bool = true + + /// Speed of card dealing (1.0 = normal, 0.5 = fast, 2.0 = slow) + var dealingSpeed: Double = 1.0 + + // MARK: - Display Settings + + /// Whether to show the cards remaining indicator. + var showCardsRemaining: Bool = true + + /// Whether to show the history road map. + var showHistory: Bool = true + + // MARK: - Persistence Keys + + private enum Keys { + static let deckCount = "settings.deckCount" + static let tableLimits = "settings.tableLimits" + static let startingBalance = "settings.startingBalance" + static let showAnimations = "settings.showAnimations" + static let dealingSpeed = "settings.dealingSpeed" + static let showCardsRemaining = "settings.showCardsRemaining" + static let showHistory = "settings.showHistory" + } + + // MARK: - Initialization + + init() { + load() + } + + // MARK: - Persistence + + /// Loads settings from UserDefaults. + func load() { + let defaults = UserDefaults.standard + + if let rawDeckCount = defaults.object(forKey: Keys.deckCount) as? Int, + let deckCount = DeckCount(rawValue: rawDeckCount) { + self.deckCount = deckCount + } + + if let rawTableLimits = defaults.string(forKey: Keys.tableLimits), + let tableLimits = TableLimits(rawValue: rawTableLimits) { + self.tableLimits = tableLimits + } + + if let balance = defaults.object(forKey: Keys.startingBalance) as? Int { + self.startingBalance = balance + } + + if defaults.object(forKey: Keys.showAnimations) != nil { + self.showAnimations = defaults.bool(forKey: Keys.showAnimations) + } + + if let speed = defaults.object(forKey: Keys.dealingSpeed) as? Double { + self.dealingSpeed = speed + } + + if defaults.object(forKey: Keys.showCardsRemaining) != nil { + self.showCardsRemaining = defaults.bool(forKey: Keys.showCardsRemaining) + } + + if defaults.object(forKey: Keys.showHistory) != nil { + self.showHistory = defaults.bool(forKey: Keys.showHistory) + } + } + + /// Saves settings to UserDefaults. + func save() { + let defaults = UserDefaults.standard + defaults.set(deckCount.rawValue, forKey: Keys.deckCount) + defaults.set(tableLimits.rawValue, forKey: Keys.tableLimits) + defaults.set(startingBalance, forKey: Keys.startingBalance) + defaults.set(showAnimations, forKey: Keys.showAnimations) + defaults.set(dealingSpeed, forKey: Keys.dealingSpeed) + defaults.set(showCardsRemaining, forKey: Keys.showCardsRemaining) + defaults.set(showHistory, forKey: Keys.showHistory) + } + + /// Resets all settings to defaults. + func resetToDefaults() { + deckCount = .eight + tableLimits = .low + startingBalance = 10_000 + showAnimations = true + dealingSpeed = 1.0 + showCardsRemaining = true + showHistory = true + save() + } +} diff --git a/Baccarat/Models/Hand.swift b/Baccarat/Models/Hand.swift new file mode 100644 index 0000000..1520faf --- /dev/null +++ b/Baccarat/Models/Hand.swift @@ -0,0 +1,47 @@ +// +// Hand.swift +// Baccarat +// +// Represents a baccarat hand (Player or Banker) with cards and scoring. +// + +import Foundation + +/// Represents a hand of cards in baccarat. +struct Hand: Identifiable { + let id = UUID() + private(set) var cards: [Card] = [] + + /// The total baccarat value of the hand (0-9). + var value: Int { + let total = cards.reduce(0) { $0 + $1.baccaratValue } + return total % 10 + } + + /// Whether this hand is a "natural" (8 or 9 on first two cards). + var isNatural: Bool { + cards.count == 2 && value >= 8 + } + + /// The number of cards in this hand. + var cardCount: Int { + cards.count + } + + /// Adds a card to the hand. + /// - Parameter card: The card to add. + mutating func addCard(_ card: Card) { + cards.append(card) + } + + /// Clears all cards from the hand. + mutating func clear() { + cards = [] + } + + /// Returns the third card if one was drawn, nil otherwise. + var thirdCard: Card? { + cards.count >= 3 ? cards[2] : nil + } +} + diff --git a/Baccarat/Models/Shoe.swift b/Baccarat/Models/Shoe.swift new file mode 100644 index 0000000..6695299 --- /dev/null +++ b/Baccarat/Models/Shoe.swift @@ -0,0 +1,70 @@ +// +// Shoe.swift +// Baccarat +// +// A shoe containing multiple decks of cards, used in baccarat. +// + +import Foundation + +/// Represents a shoe containing multiple decks of cards. +/// Standard baccarat uses 6-8 decks shuffled together. +struct Shoe { + private var cards: [Card] = [] + + /// The number of decks in this shoe. + let deckCount: Int + + /// Number of cards remaining in the shoe. + var cardsRemaining: Int { + cards.count + } + + /// Whether the shoe needs to be reshuffled (less than 20% remaining). + var needsReshuffle: Bool { + let totalCards = deckCount * 52 + return cards.count < totalCards / 5 + } + + /// Creates a new shoe with the specified number of decks. + /// - Parameter deckCount: Number of decks to include (default: 8). + init(deckCount: Int = 8) { + self.deckCount = deckCount + shuffle() + } + + /// Shuffles all decks together to create a fresh shoe. + mutating func shuffle() { + cards = [] + + for _ in 0.. Card? { + guard !cards.isEmpty else { return nil } + return cards.removeFirst() + } + + /// Burns (discards) a specified number of cards from the shoe. + /// - Parameter count: Number of cards to burn. + mutating func burn(_ count: Int) { + for _ in 0.., balance: Int, maxBet: Int = 100_000) { + self._selectedChip = selectedChip + self.balance = balance + self.maxBet = maxBet + } + + /// Chips that are unlocked based on current balance. + private var availableChips: [ChipDenomination] { + ChipDenomination.availableChips(forBalance: balance) + .filter { $0.rawValue <= maxBet } // Don't show chips larger than max bet + } + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 10) { + ForEach(availableChips) { denomination in + Button { + selectedChip = denomination + } label: { + ChipView( + denomination: denomination, + size: 50, + isSelected: selectedChip == denomination + ) + } + .buttonStyle(.plain) + .opacity(balance >= denomination.rawValue ? 1.0 : 0.4) + .disabled(balance < denomination.rawValue) + } + } + .padding(.horizontal) + .padding(.vertical, 6) // Extra padding for selection scale effect + } + .scrollIndicators(.hidden) + .onChange(of: balance) { _, newBalance in + // Auto-select highest affordable chip if current selection is now too expensive + if newBalance < selectedChip.rawValue { + if let affordable = availableChips.last(where: { newBalance >= $0.rawValue }) { + selectedChip = affordable + } + } + } + } +} + +#Preview { + ZStack { + Color(red: 0.0, green: 0.3, blue: 0.15) + .ignoresSafeArea() + + VStack(spacing: 20) { + Text("Balance: $50,000") + .foregroundStyle(.white) + + ChipSelectorView( + selectedChip: .constant(.fiveThousand), + balance: 50_000, + maxBet: 50_000 + ) + } + } +} + diff --git a/Baccarat/Views/ChipView.swift b/Baccarat/Views/ChipView.swift new file mode 100644 index 0000000..35eaae5 --- /dev/null +++ b/Baccarat/Views/ChipView.swift @@ -0,0 +1,291 @@ +// +// ChipView.swift +// Baccarat +// +// Casino-style betting chips with realistic design. +// + +import SwiftUI + +/// The available chip denominations. +enum ChipDenomination: Int, CaseIterable, Identifiable { + case ten = 10 + case twentyFive = 25 + case fifty = 50 + case hundred = 100 + case fiveHundred = 500 + case thousand = 1_000 + case fiveThousand = 5_000 + case tenThousand = 10_000 + case twentyFiveThousand = 25_000 + case fiftyThousand = 50_000 + case hundredThousand = 100_000 + + var id: Int { rawValue } + + /// The display text for this denomination. + var displayText: String { + switch self { + case .ten: return "10" + case .twentyFive: return "25" + case .fifty: return "50" + case .hundred: return "100" + case .fiveHundred: return "500" + case .thousand: return "1K" + case .fiveThousand: return "5K" + case .tenThousand: return "10K" + case .twentyFiveThousand: return "25K" + case .fiftyThousand: return "50K" + case .hundredThousand: return "100K" + } + } + + /// The minimum balance required to show this chip in the selector. + /// Higher chips unlock as you win more! + var unlockBalance: Int { + switch self { + case .ten, .twentyFive, .fifty, .hundred: return 0 // Always available + case .fiveHundred: return 500 + case .thousand: return 1_000 + case .fiveThousand: return 5_000 + case .tenThousand: return 10_000 + case .twentyFiveThousand: return 25_000 + case .fiftyThousand: return 50_000 + case .hundredThousand: return 100_000 + } + } + + /// Whether this chip should be shown based on the player's balance. + func isUnlocked(forBalance balance: Int) -> Bool { + balance >= unlockBalance + } + + /// Returns chips that should be visible for a given balance. + static func availableChips(forBalance balance: Int) -> [ChipDenomination] { + allCases.filter { $0.isUnlocked(forBalance: balance) } + } + + /// The primary color for this chip. + var primaryColor: Color { + switch self { + case .ten: return Color(red: 0.2, green: 0.4, blue: 0.8) // Blue + case .twentyFive: return Color(red: 0.1, green: 0.6, blue: 0.3) // Green + case .fifty: return Color(red: 0.8, green: 0.5, blue: 0.1) // Orange + case .hundred: return Color(red: 0.1, green: 0.1, blue: 0.1) // Black + case .fiveHundred: return Color(red: 0.6, green: 0.2, blue: 0.6) // Purple + case .thousand: return Color(red: 0.8, green: 0.65, blue: 0.2) // Gold + case .fiveThousand: return Color(red: 0.7, green: 0.1, blue: 0.2) // Crimson + case .tenThousand: return Color(red: 0.2, green: 0.5, blue: 0.5) // Teal + case .twentyFiveThousand: return Color(red: 0.5, green: 0.3, blue: 0.1) // Bronze + case .fiftyThousand: return Color(red: 0.75, green: 0.75, blue: 0.8) // Platinum + case .hundredThousand: return Color(red: 0.9, green: 0.1, blue: 0.3) // Ruby + } + } + + /// The secondary/accent color for this chip. + var secondaryColor: Color { + switch self { + case .ten: return Color(red: 0.3, green: 0.5, blue: 0.9) + case .twentyFive: return Color(red: 0.2, green: 0.7, blue: 0.4) + case .fifty: return Color(red: 0.9, green: 0.6, blue: 0.2) + case .hundred: return Color(red: 0.3, green: 0.3, blue: 0.3) + case .fiveHundred: return Color(red: 0.7, green: 0.3, blue: 0.7) + case .thousand: return Color(red: 0.9, green: 0.75, blue: 0.3) + case .fiveThousand: return Color(red: 0.85, green: 0.2, blue: 0.3) + case .tenThousand: return Color(red: 0.3, green: 0.6, blue: 0.6) + case .twentyFiveThousand: return Color(red: 0.65, green: 0.45, blue: 0.2) + case .fiftyThousand: return Color(red: 0.85, green: 0.85, blue: 0.9) + case .hundredThousand: return Color(red: 1.0, green: 0.2, blue: 0.4) + } + } + + /// The edge stripe color for this chip. + var stripeColor: Color { + switch self { + case .ten, .twentyFive, .fifty: return .white + case .hundred: return Color(red: 0.9, green: 0.75, blue: 0.3) + case .fiveHundred: return .white + case .thousand: return .black + case .fiveThousand: return Color(red: 0.9, green: 0.75, blue: 0.3) // Gold stripes + case .tenThousand: return .white + case .twentyFiveThousand: return Color(red: 0.9, green: 0.75, blue: 0.3) + case .fiftyThousand: return Color(red: 0.2, green: 0.2, blue: 0.3) // Dark stripes + case .hundredThousand: return Color(red: 0.9, green: 0.85, blue: 0.3) // Gold stripes + } + } +} + +/// A realistic casino-style betting chip. +struct ChipView: View { + let denomination: ChipDenomination + let size: CGFloat + var isSelected: Bool = false + + init(denomination: ChipDenomination, size: CGFloat = 60, isSelected: Bool = false) { + self.denomination = denomination + self.size = size + self.isSelected = isSelected + } + + var body: some View { + ZStack { + // Base circle with gradient + Circle() + .fill( + RadialGradient( + colors: [ + denomination.secondaryColor, + denomination.primaryColor, + denomination.primaryColor.opacity(0.8) + ], + center: .topLeading, + startRadius: 0, + endRadius: size + ) + ) + + // Edge stripes pattern + ChipEdgePattern(stripeColor: denomination.stripeColor) + .clipShape(.circle) + + // Inner circle + Circle() + .fill( + RadialGradient( + colors: [ + denomination.secondaryColor, + denomination.primaryColor + ], + center: .topLeading, + startRadius: 0, + endRadius: size * 0.4 + ) + ) + .frame(width: size * 0.65, height: size * 0.65) + + // Inner border + Circle() + .strokeBorder( + denomination.stripeColor.opacity(0.8), + lineWidth: 2 + ) + .frame(width: size * 0.65, height: size * 0.65) + + // Denomination text + Text(denomination.displayText) + .font(.system(size: size * 0.25, weight: .heavy, design: .rounded)) + .foregroundStyle(denomination.stripeColor) + .shadow(color: .black.opacity(0.3), radius: 1, x: 1, y: 1) + + // Outer border + Circle() + .strokeBorder( + LinearGradient( + colors: [ + Color.white.opacity(0.4), + Color.black.opacity(0.3) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 2 + ) + + // Selection glow + if isSelected { + Circle() + .strokeBorder(Color.yellow, lineWidth: 3) + .frame(width: size + 6, height: size + 6) + } + } + .frame(width: size, height: size) + .shadow(color: .black.opacity(0.4), radius: isSelected ? 8 : 4, x: 2, y: 3) + .scaleEffect(isSelected ? 1.1 : 1.0) + .animation(.spring(duration: 0.2), value: isSelected) + } +} + +/// The edge stripe pattern for chips. +struct ChipEdgePattern: View { + let stripeColor: Color + + var body: some View { + Canvas { context, size in + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let radius = min(size.width, size.height) / 2 + let stripeCount = 16 + let stripeWidth: CGFloat = 6 + + for i in 0.. 0 { + result.append((denom, min(count, maxChips))) + remaining -= count * denom.rawValue + } + } + + return result + } + + var body: some View { + ZStack { + ForEach(chipBreakdown.indices, id: \.self) { index in + let (denom, _) = chipBreakdown[index] + ChipView(denomination: denom, size: 40) + .offset(y: CGFloat(-index * 4)) + } + } + } +} + +#Preview { + ZStack { + Color(red: 0.0, green: 0.3, blue: 0.15) + .ignoresSafeArea() + + VStack(spacing: 30) { + HStack(spacing: 15) { + ForEach(ChipDenomination.allCases) { denom in + ChipView(denomination: denom, size: 50) + } + } + + ChipView(denomination: .hundred, size: 80, isSelected: true) + } + } +} + diff --git a/Baccarat/Views/GameTableView.swift b/Baccarat/Views/GameTableView.swift new file mode 100644 index 0000000..488b107 --- /dev/null +++ b/Baccarat/Views/GameTableView.swift @@ -0,0 +1,609 @@ +// +// GameTableView.swift +// Baccarat +// +// The main baccarat table view with all game elements. +// + +import SwiftUI + +/// The main game table view containing all game elements. +struct GameTableView: View { + @State private var settings = GameSettings() + @State private var gameState: GameState? + @State private var selectedChip: ChipDenomination = .hundred + @State private var showSettings = false + + private var state: GameState { + gameState ?? GameState(settings: settings) + } + + private var playerIsWinner: Bool { + state.lastResult == .playerWins + } + + private var bankerIsWinner: Bool { + state.lastResult == .bankerWins + } + + private var isTie: Bool { + state.lastResult == .tie + } + + var body: some View { + ZStack { + // Table background + TableBackgroundView() + + // Main content + VStack(spacing: 0) { + // Top bar with balance and info + TopBarView( + balance: state.balance, + cardsRemaining: state.engine.shoe.cardsRemaining, + showCardsRemaining: settings.showCardsRemaining, + onReset: { state.resetGame() }, + onSettings: { showSettings = true } + ) + + Spacer(minLength: 4) + + // Cards display area + CardsDisplayArea( + playerCards: state.visiblePlayerCards, + bankerCards: state.visibleBankerCards, + playerCardsFaceUp: state.playerCardsFaceUp, + bankerCardsFaceUp: state.bankerCardsFaceUp, + playerValue: state.playerHandValue, + bankerValue: state.bankerHandValue, + playerIsWinner: playerIsWinner, + bankerIsWinner: bankerIsWinner, + isTie: isTie + ) + + Spacer(minLength: 4) + + // Road map history + if settings.showHistory && !state.roundHistory.isEmpty { + RoadMapView(results: state.recentResults) + .padding(.horizontal) + } + + Spacer(minLength: 8) + + // Mini Baccarat betting table + MiniBaccaratTableView( + gameState: state, + selectedChip: selectedChip + ) + .padding(.horizontal, 12) + + Spacer(minLength: 8) + + // Chip selector - shows higher chips as you win more! + ChipSelectorView( + selectedChip: $selectedChip, + balance: state.balance, + maxBet: state.maxBet + ) + .padding(.bottom, 12) + + // Action buttons + ActionButtonsView( + gameState: state, + onDeal: { + Task { + await state.deal() + } + }, + onClear: { state.clearBets() }, + onNewRound: { state.newRound() } + ) + .padding(.horizontal) + .padding(.bottom, 4) + } + .safeAreaPadding(.bottom) + + // Result banner overlay + if state.showResultBanner, let result = state.lastResult { + ResultBannerView( + result: result, + winnings: state.lastWinnings + ) + .transition(.opacity) + + // Confetti for wins + if state.lastWinnings > 0 { + ConfettiView() + } + } + + // Game Over overlay when broke + if state.balance == 0 && state.currentBets.isEmpty && !state.isAnimating { + GameOverView( + roundsPlayed: state.roundHistory.count, + onPlayAgain: { state.resetGame() } + ) + .transition(.opacity) + } + } + .onAppear { + if gameState == nil { + gameState = GameState(settings: settings) + } + } + .sheet(isPresented: $showSettings) { + SettingsView(settings: settings) { + // Apply settings when changed + gameState?.applySettings() + } + } + } +} + +/// Game over screen shown when player runs out of money. +struct GameOverView: View { + let roundsPlayed: Int + let onPlayAgain: () -> Void + + @State private var showContent = false + + var body: some View { + ZStack { + // Solid dark backdrop - fully opaque + Color.black + .ignoresSafeArea() + + // Modal card + VStack(spacing: 28) { + // Broke icon + Image(systemName: "creditcard.trianglebadge.exclamationmark") + .font(.system(size: 70)) + .foregroundStyle(.red) + .symbolEffect(.pulse, options: .repeating) + + // Title + Text("GAME OVER") + .font(.system(size: 36, weight: .black, design: .rounded)) + .foregroundStyle(.white) + + // Message + Text("You've run out of chips!") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.white.opacity(0.7)) + + // Stats card + VStack(spacing: 12) { + HStack { + Text("Rounds Played") + .foregroundStyle(.white.opacity(0.6)) + Spacer() + Text("\(roundsPlayed)") + .bold() + .foregroundStyle(.white) + } + } + .font(.system(size: 17)) + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Color.white.opacity(0.1), lineWidth: 1) + ) + ) + .padding(.horizontal, 20) + + // Play Again button + Button { + onPlayAgain() + } label: { + HStack(spacing: 8) { + Image(systemName: "arrow.counterclockwise") + Text("Play Again") + } + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, 48) + .padding(.vertical, 18) + .background( + Capsule() + .fill( + LinearGradient( + colors: [ + Color(red: 1.0, green: 0.85, blue: 0.3), + Color(red: 0.9, green: 0.7, blue: 0.2) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + ) + .shadow(color: .yellow.opacity(0.4), radius: 12) + } + .padding(.top, 12) + } + .padding(32) + .background( + RoundedRectangle(cornerRadius: 28) + .fill( + LinearGradient( + colors: [ + Color(red: 0.12, green: 0.12, blue: 0.14), + Color(red: 0.08, green: 0.08, blue: 0.1) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 28) + .strokeBorder( + LinearGradient( + colors: [ + Color.red.opacity(0.5), + Color.red.opacity(0.2) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 2 + ) + ) + ) + .shadow(color: .red.opacity(0.2), radius: 30) + .padding(.horizontal, 24) + .scaleEffect(showContent ? 1.0 : 0.8) + .opacity(showContent ? 1.0 : 0) + } + .onAppear { + withAnimation(.spring(duration: 0.4, bounce: 0.3)) { + showContent = true + } + } + } +} + +/// The cards display area showing both hands. +struct CardsDisplayArea: View { + let playerCards: [Card] + let bankerCards: [Card] + let playerCardsFaceUp: [Bool] + let bankerCardsFaceUp: [Bool] + let playerValue: Int + let bankerValue: Int + let playerIsWinner: Bool + let bankerIsWinner: Bool + let isTie: Bool + + var body: some View { + HStack(spacing: 32) { + // Player side + VStack(spacing: 10) { + // Label with value + HStack(spacing: 8) { + Text("PLAYER") + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + if !playerCards.isEmpty && playerCardsFaceUp.contains(true) { + ValueBadge(value: playerValue, color: .blue) + } + } + .frame(height: 30) + + // Cards + CompactHandView( + cards: playerCards, + cardsFaceUp: playerCardsFaceUp, + isWinner: playerIsWinner + ) + } + + // Banker side + VStack(spacing: 10) { + // Label with value + HStack(spacing: 8) { + Text("BANKER") + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + if !bankerCards.isEmpty && bankerCardsFaceUp.contains(true) { + ValueBadge(value: bankerValue, color: .red) + } + } + .frame(height: 30) + + // Cards + CompactHandView( + cards: bankerCards, + cardsFaceUp: bankerCardsFaceUp, + isWinner: bankerIsWinner + ) + } + } + .padding(.top, 16) + .padding(.bottom, 14) + .padding(.horizontal, 20) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color.black.opacity(0.25)) + ) + .padding(.horizontal) + } +} + +/// A compact hand view showing cards in a row. +struct CompactHandView: View { + let cards: [Card] + let cardsFaceUp: [Bool] + let isWinner: Bool + + var body: some View { + HStack(spacing: -12) { + if cards.isEmpty { + // Placeholders + ForEach(0..<2, id: \.self) { _ in + CardPlaceholderView(width: 45) + } + } else { + ForEach(cards.indices, id: \.self) { index in + let isFaceUp = index < cardsFaceUp.count ? cardsFaceUp[index] : false + CardView( + card: cards[index], + isFaceUp: isFaceUp, + cardWidth: 45 + ) + .zIndex(Double(index)) + } + } + } + .padding(6) + .background( + RoundedRectangle(cornerRadius: 8) + .strokeBorder( + isWinner ? Color.yellow : Color.clear, + lineWidth: 2 + ) + ) + .overlay(alignment: .bottom) { + if isWinner { + Text("WIN") + .font(.system(size: 10, weight: .black)) + .foregroundStyle(.black) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color.yellow) + ) + .offset(y: 10) + } + } + } +} + +/// A small badge showing the hand value. +struct ValueBadge: View { + let value: Int + let color: Color + + var body: some View { + Text("\(value)") + .font(.system(size: 15, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .frame(width: 26, height: 26) + .background( + Circle() + .fill(color) + ) + } +} + +/// The casino table background. +struct TableBackgroundView: View { + var body: some View { + ZStack { + // Base dark green + Color(red: 0.02, green: 0.15, blue: 0.08) + + // Radial gradient for depth + RadialGradient( + colors: [ + Color(red: 0.03, green: 0.25, blue: 0.12), + Color(red: 0.01, green: 0.12, blue: 0.06) + ], + center: .center, + startRadius: 50, + endRadius: 500 + ) + + // Subtle felt texture + FeltPatternView() + .opacity(0.03) + } + .ignoresSafeArea() + } +} + +/// Subtle felt texture pattern. +struct FeltPatternView: View { + var body: some View { + Canvas { context, size in + for _ in 0..<2000 { + let x = Double.random(in: 0...size.width) + let y = Double.random(in: 0...size.height) + let dotSize = Double.random(in: 1...2) + + let rect = CGRect(x: x, y: y, width: dotSize, height: dotSize) + context.fill(Path(ellipseIn: rect), with: .color(.white)) + } + } + } +} + +/// Top bar showing balance and game info. +struct TopBarView: View { + let balance: Int + let cardsRemaining: Int + let showCardsRemaining: Bool + let onReset: () -> Void + let onSettings: () -> Void + + var body: some View { + HStack { + // Balance display + VStack(alignment: .leading, spacing: 2) { + Text("BALANCE") + .font(.system(size: 9, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.6)) + .tracking(1) + + HStack(spacing: 4) { + Text("$") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.yellow.opacity(0.8)) + + Text(balance, format: .number) + .font(.system(size: 20, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .contentTransition(.numericText()) + .animation(.spring(duration: 0.3), value: balance) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background( + Capsule() + .fill(Color.black.opacity(0.4)) + ) + + Spacer() + + // Cards remaining indicator (if enabled) + if showCardsRemaining { + HStack(spacing: 4) { + Image(systemName: "rectangle.portrait.on.rectangle.portrait.fill") + .font(.system(size: 12)) + Text("\(cardsRemaining)") + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(.white.opacity(0.5)) + + Spacer() + } + + // Settings button + Button("Settings", systemImage: "gearshape.fill", action: onSettings) + .labelStyle(.iconOnly) + .font(.system(size: 16)) + .foregroundStyle(.white.opacity(0.6)) + .padding(8) + .background( + Circle() + .fill(Color.black.opacity(0.4)) + ) + + // Reset button + Button("Reset", systemImage: "arrow.counterclockwise", action: onReset) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.white.opacity(0.6)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule() + .fill(Color.black.opacity(0.4)) + ) + } + .padding(.horizontal) + .padding(.top, 4) + } +} + +/// Action buttons for deal, clear, and new round. +struct ActionButtonsView: View { + @Bindable var gameState: GameState + let onDeal: () -> Void + let onClear: () -> Void + let onNewRound: () -> Void + + var body: some View { + HStack(spacing: 12) { + if gameState.currentPhase == .betting { + // Clear bets button + Button("Clear", systemImage: "xmark.circle", action: onClear) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background( + Capsule() + .fill(Color(red: 0.6, green: 0.2, blue: 0.2)) + ) + .opacity(gameState.currentBets.isEmpty ? 0.5 : 1.0) + .disabled(gameState.currentBets.isEmpty) + + // Deal button + Button("Deal", systemImage: "play.fill", action: onDeal) + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, 32) + .padding(.vertical, 12) + .background( + Capsule() + .fill( + LinearGradient( + colors: [ + Color(red: 1.0, green: 0.85, blue: 0.3), + Color(red: 0.9, green: 0.7, blue: 0.2) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + ) + .shadow(color: .yellow.opacity(0.3), radius: 6) + .opacity(gameState.canDeal ? 1.0 : 0.5) + .disabled(!gameState.canDeal) + } else if gameState.currentPhase == .roundComplete { + // New round button + Button("New Round", systemImage: "arrow.right.circle", action: onNewRound) + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, 32) + .padding(.vertical, 12) + .background( + Capsule() + .fill( + LinearGradient( + colors: [ + Color(red: 1.0, green: 0.85, blue: 0.3), + Color(red: 0.9, green: 0.7, blue: 0.2) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + ) + .shadow(color: .yellow.opacity(0.3), radius: 6) + } else { + // Playing indicator + HStack(spacing: 6) { + ProgressView() + .tint(.white) + .scaleEffect(0.8) + Text("Dealing...") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.white.opacity(0.8)) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + } + } +} + +#Preview { + GameTableView() +} diff --git a/Baccarat/Views/MiniBaccaratTableView.swift b/Baccarat/Views/MiniBaccaratTableView.swift new file mode 100644 index 0000000..7301b18 --- /dev/null +++ b/Baccarat/Views/MiniBaccaratTableView.swift @@ -0,0 +1,475 @@ +// +// MiniBaccaratTableView.swift +// Baccarat +// +// A realistic mini baccarat table layout for single player. +// + +import SwiftUI + +/// The semi-circular mini baccarat betting table layout. +struct MiniBaccaratTableView: View { + @Bindable var gameState: GameState + let selectedChip: ChipDenomination + + private func betAmount(for type: BetType) -> Int { + gameState.betAmount(for: type) + } + + private func canAddBet(for type: BetType) -> Bool { + gameState.canPlaceBet && + gameState.balance >= selectedChip.rawValue && + gameState.canAddToBet(type: type, amount: selectedChip.rawValue) + } + + private func isAtMax(for type: BetType) -> Bool { + betAmount(for: type) >= gameState.maxBet + } + + private var isPlayerSelected: Bool { + gameState.mainBet?.type == .player + } + + private var isBankerSelected: Bool { + gameState.mainBet?.type == .banker + } + + var body: some View { + VStack(spacing: 4) { + // Table limits label + Text("TABLE LIMITS: $\(gameState.minBet) - $\(gameState.maxBet.formatted())") + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.5)) + .tracking(1) + + ZStack { + // Table felt background with arc shape + TableFeltShape() + .fill( + LinearGradient( + colors: [ + Color(red: 0.0, green: 0.35, blue: 0.18), + Color(red: 0.0, green: 0.28, blue: 0.12) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + + // Gold border + TableFeltShape() + .strokeBorder( + LinearGradient( + colors: [ + Color(red: 0.85, green: 0.7, blue: 0.35), + Color(red: 0.65, green: 0.5, blue: 0.2) + ], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 4 + ) + + // 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: 55) + .padding(.horizontal, 50) + .padding(.top, 12) + + Spacer(minLength: 8) + + // BANKER zone in middle + BankerBettingZone( + betAmount: betAmount(for: .banker), + isSelected: isBankerSelected, + isEnabled: canAddBet(for: .banker), + isAtMax: isAtMax(for: .banker) + ) { + gameState.placeBet(type: .banker, amount: selectedChip.rawValue) + } + .frame(height: 60) + .padding(.horizontal, 30) + + Spacer(minLength: 8) + + // PLAYER zone at bottom + PlayerBettingZone( + betAmount: betAmount(for: .player), + isSelected: isPlayerSelected, + isEnabled: canAddBet(for: .player), + isAtMax: isAtMax(for: .player) + ) { + gameState.placeBet(type: .player, amount: selectedChip.rawValue) + } + .frame(height: 60) + .padding(.horizontal, 20) + .padding(.bottom, 12) + } + } + .aspectRatio(1.6, contentMode: .fit) + } + } +} + +/// Custom shape for the mini baccarat table felt. +struct TableFeltShape: InsettableShape { + var insetAmount: CGFloat = 0 + + func path(in rect: CGRect) -> Path { + var path = Path() + + let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount) + let height = insetRect.height + let cornerRadius: CGFloat = 20 + + // Start from bottom left + path.move(to: CGPoint(x: insetRect.minX + cornerRadius, y: insetRect.maxY)) + + // Bottom edge + path.addLine(to: CGPoint(x: insetRect.maxX - cornerRadius, y: insetRect.maxY)) + + // Bottom right corner + path.addQuadCurve( + to: CGPoint(x: insetRect.maxX, y: insetRect.maxY - cornerRadius), + control: CGPoint(x: insetRect.maxX, y: insetRect.maxY) + ) + + // Right edge going up with curve + path.addLine(to: CGPoint(x: insetRect.maxX, y: insetRect.minY + height * 0.3)) + + // Top arc + path.addQuadCurve( + to: CGPoint(x: insetRect.minX, y: insetRect.minY + height * 0.3), + control: CGPoint(x: insetRect.midX, y: insetRect.minY - height * 0.1) + ) + + // Left edge going down + path.addLine(to: CGPoint(x: insetRect.minX, y: insetRect.maxY - cornerRadius)) + + // Bottom left corner + path.addQuadCurve( + to: CGPoint(x: insetRect.minX + cornerRadius, y: insetRect.maxY), + control: CGPoint(x: insetRect.minX, y: insetRect.maxY) + ) + + path.closeSubpath() + return path + } + + func inset(by amount: CGFloat) -> some InsettableShape { + var shape = self + shape.insetAmount += amount + return shape + } +} + +/// 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 + + private var backgroundColor: Color { + if isAtMax { + // Darker/muted green when at max + return Color(red: 0.08, green: 0.32, blue: 0.18) + } + return Color(red: 0.1, green: 0.45, blue: 0.25) + } + + private var borderColor: Color { + if isAtMax { + // Silver border when at max + return Color(red: 0.6, green: 0.6, blue: 0.65) + } + return Color(red: 0.7, green: 0.55, blue: 0.25) + } + + var body: some View { + Button { + if isEnabled { + action() + } + } label: { + ZStack { + // Background + RoundedRectangle(cornerRadius: 8) + .fill(backgroundColor) + + // Border + RoundedRectangle(cornerRadius: 8) + .strokeBorder(borderColor, lineWidth: 2) + + // Centered text content + VStack(spacing: 2) { + Text("TIE") + .font(.system(size: 14, weight: .black, design: .rounded)) + .tracking(2) + + Text("PAYS 8 TO 1") + .font(.system(size: 9, weight: .medium)) + .opacity(0.8) + } + .foregroundStyle(.white) + } + // Chip overlaid on right side + .overlay(alignment: .trailing) { + if betAmount > 0 { + ChipOnTable(amount: betAmount, showMax: isAtMax) + .padding(.trailing, 8) + } + } + } + .buttonStyle(.plain) + } +} + +/// 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 + + private var backgroundColors: [Color] { + if isAtMax { + // Darker/muted red when at max + return [ + Color(red: 0.4, green: 0.1, blue: 0.1), + Color(red: 0.28, green: 0.06, blue: 0.06) + ] + } + return [ + Color(red: 0.55, green: 0.12, blue: 0.12), + Color(red: 0.4, green: 0.08, blue: 0.08) + ] + } + + private var borderColor: Color { + if isAtMax { + return Color(red: 0.6, green: 0.6, blue: 0.65) + } + return Color(red: 0.7, green: 0.55, blue: 0.25) + } + + var body: some View { + Button { + if isEnabled { + action() + } + } label: { + ZStack { + // Background + RoundedRectangle(cornerRadius: 10) + .fill( + LinearGradient( + colors: backgroundColors, + startPoint: .top, + endPoint: .bottom + ) + ) + + // Selection glow + if isSelected { + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.yellow, lineWidth: 3) + .shadow(color: .yellow.opacity(0.5), radius: 8) + } + + // Border + RoundedRectangle(cornerRadius: 10) + .strokeBorder(borderColor, lineWidth: 2) + + // Centered text content + VStack(spacing: 2) { + Text("BANKER") + .font(.system(size: 16, weight: .black, design: .rounded)) + .tracking(3) + + Text("PAYS 0.95 TO 1") + .font(.system(size: 9, weight: .medium)) + .opacity(0.8) + } + .foregroundStyle(.white) + } + // Chip overlaid on right side + .overlay(alignment: .trailing) { + if betAmount > 0 { + ChipOnTable(amount: betAmount, showMax: isAtMax) + .padding(.trailing, 12) + } + } + } + .buttonStyle(.plain) + } +} + +/// 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 + + private var backgroundColors: [Color] { + if isAtMax { + // Darker/muted blue when at max + return [ + Color(red: 0.08, green: 0.18, blue: 0.4), + Color(red: 0.04, green: 0.1, blue: 0.28) + ] + } + return [ + Color(red: 0.1, green: 0.25, blue: 0.55), + Color(red: 0.05, green: 0.15, blue: 0.4) + ] + } + + private var borderColor: Color { + if isAtMax { + return Color(red: 0.6, green: 0.6, blue: 0.65) + } + return Color(red: 0.7, green: 0.55, blue: 0.25) + } + + var body: some View { + Button { + if isEnabled { + action() + } + } label: { + ZStack { + // Background + RoundedRectangle(cornerRadius: 10) + .fill( + LinearGradient( + colors: backgroundColors, + startPoint: .top, + endPoint: .bottom + ) + ) + + // Selection glow + if isSelected { + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.yellow, lineWidth: 3) + .shadow(color: .yellow.opacity(0.5), radius: 8) + } + + // Border + RoundedRectangle(cornerRadius: 10) + .strokeBorder(borderColor, lineWidth: 2) + + // Centered text content + VStack(spacing: 2) { + Text("PLAYER") + .font(.system(size: 16, weight: .black, design: .rounded)) + .tracking(3) + + Text("PAYS 1 TO 1") + .font(.system(size: 9, weight: .medium)) + .opacity(0.8) + } + .foregroundStyle(.white) + } + // Chip overlaid on right side + .overlay(alignment: .trailing) { + if betAmount > 0 { + ChipOnTable(amount: betAmount, showMax: isAtMax) + .padding(.trailing, 12) + } + } + } + .buttonStyle(.plain) + } +} + +/// A chip displayed on the table showing bet amount. +struct ChipOnTable: View { + let amount: Int + var showMax: Bool = false + + private var chipColor: Color { + switch amount { + case 0..<50: return .blue + case 50..<100: return .orange + case 100..<500: return .black + case 500..<1000: return .purple + default: return Color(red: 0.8, green: 0.65, blue: 0.2) + } + } + + private var displayText: String { + if amount >= 1000 { + return "\(amount / 1000)K" + } else { + return "\(amount)" + } + } + + var body: some View { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [chipColor.opacity(0.9), chipColor], + center: .topLeading, + startRadius: 0, + endRadius: 20 + ) + ) + .frame(width: 36, height: 36) + + Circle() + .strokeBorder(Color.white.opacity(0.8), lineWidth: 2) + .frame(width: 36, height: 36) + + Circle() + .strokeBorder(Color.white.opacity(0.4), lineWidth: 1) + .frame(width: 26, height: 26) + + Text(displayText) + .font(.system(size: amount >= 1000 ? 10 : 11, weight: .bold)) + .foregroundStyle(.white) + } + .shadow(color: .black.opacity(0.4), radius: 3, x: 1, y: 2) + .overlay(alignment: .topTrailing) { + if showMax { + Text("MAX") + .font(.system(size: 7, weight: .black)) + .foregroundStyle(.white) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color.red) + ) + .offset(x: 6, y: -4) + } + } + } +} + +#Preview { + ZStack { + Color(red: 0.05, green: 0.2, blue: 0.1) + .ignoresSafeArea() + + MiniBaccaratTableView( + gameState: GameState(), + selectedChip: .hundred + ) + .padding() + } +} diff --git a/Baccarat/Views/ResultBannerView.swift b/Baccarat/Views/ResultBannerView.swift new file mode 100644 index 0000000..672b85a --- /dev/null +++ b/Baccarat/Views/ResultBannerView.swift @@ -0,0 +1,163 @@ +// +// ResultBannerView.swift +// Baccarat +// +// Animated result banner showing the winner and winnings. +// + +import SwiftUI + +/// An animated banner showing the round result. +struct ResultBannerView: View { + let result: GameResult + let winnings: Int + + @State private var showBanner = false + @State private var showText = false + @State private var showWinnings = false + + var body: some View { + ZStack { + // Background overlay + Color.black.opacity(showBanner ? 0.5 : 0) + .ignoresSafeArea() + .animation(.easeIn(duration: 0.3), value: showBanner) + + // Banner + VStack(spacing: 20) { + // Result text + Text(result.displayText) + .font(.system(size: 36, weight: .black, design: .rounded)) + .foregroundStyle( + LinearGradient( + colors: [.white, result.color], + startPoint: .top, + endPoint: .bottom + ) + ) + .shadow(color: result.color.opacity(0.8), radius: 10) + .scaleEffect(showText ? 1.0 : 0.5) + .opacity(showText ? 1.0 : 0) + + // Winnings display + if winnings != 0 { + HStack(spacing: 8) { + if winnings > 0 { + Image(systemName: "plus.circle.fill") + .foregroundStyle(.green) + Text("\(winnings)") + .foregroundStyle(.green) + } else { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.red) + Text("\(abs(winnings))") + .foregroundStyle(.red) + } + } + .font(.system(size: 28, weight: .bold, design: .rounded)) + .scaleEffect(showWinnings ? 1.0 : 0.5) + .opacity(showWinnings ? 1.0 : 0) + } + } + .padding(40) + .background( + RoundedRectangle(cornerRadius: 24) + .fill( + LinearGradient( + colors: [ + Color(white: 0.15), + Color(white: 0.08) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 24) + .strokeBorder( + LinearGradient( + colors: [ + result.color.opacity(0.8), + result.color.opacity(0.3) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 3 + ) + ) + ) + .shadow(color: result.color.opacity(0.3), radius: 30) + .scaleEffect(showBanner ? 1.0 : 0.8) + .opacity(showBanner ? 1.0 : 0) + } + .onAppear { + withAnimation(.spring(duration: 0.4, bounce: 0.3)) { + showBanner = true + } + + withAnimation(.spring(duration: 0.4, bounce: 0.3).delay(0.2)) { + showText = true + } + + withAnimation(.spring(duration: 0.4, bounce: 0.3).delay(0.4)) { + showWinnings = true + } + } + } +} + +/// Confetti particle for celebrations. +struct ConfettiPiece: View { + let color: Color + @State private var position: CGPoint = .zero + @State private var rotation: Double = 0 + @State private var opacity: Double = 1 + + var body: some View { + Rectangle() + .fill(color) + .frame(width: 8, height: 12) + .rotationEffect(.degrees(rotation)) + .position(position) + .opacity(opacity) + .onAppear { + let screenWidth = 400.0 + let startX = Double.random(in: 0...screenWidth) + position = CGPoint(x: startX, y: -20) + + withAnimation(.easeIn(duration: Double.random(in: 2...4))) { + position = CGPoint( + x: startX + Double.random(in: -100...100), + y: 800 + ) + rotation = Double.random(in: 360...1080) + opacity = 0 + } + } + } +} + +/// A confetti celebration overlay. +struct ConfettiView: View { + let colors: [Color] = [.red, .blue, .green, .yellow, .orange, .purple, .pink] + + var body: some View { + ZStack { + ForEach(0..<50, id: \.self) { _ in + ConfettiPiece(color: colors.randomElement() ?? .yellow) + } + } + .allowsHitTesting(false) + } +} + +#Preview { + ZStack { + Color(red: 0.0, green: 0.3, blue: 0.15) + .ignoresSafeArea() + + ResultBannerView(result: .playerWins, winnings: 500) + } +} + diff --git a/Baccarat/Views/RoadMapView.swift b/Baccarat/Views/RoadMapView.swift new file mode 100644 index 0000000..3ec78af --- /dev/null +++ b/Baccarat/Views/RoadMapView.swift @@ -0,0 +1,92 @@ +// +// RoadMapView.swift +// Baccarat +// +// A visual history display of recent game results (Big Road style). +// + +import SwiftUI + +/// A simple road display showing recent results. +struct RoadMapView: View { + let results: [RoundResult] + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text("HISTORY") + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.6)) + .tracking(1) + + ScrollView(.horizontal) { + HStack(spacing: 4) { + ForEach(results) { result in + RoadDot(result: result.result) + } + } + .padding(.vertical, 4) + } + .scrollIndicators(.hidden) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.black.opacity(0.3)) + ) + } +} + +/// A single dot in the road display. +struct RoadDot: View { + let result: GameResult + + private var color: Color { + switch result { + case .playerWins: return .blue + case .bankerWins: return .red + case .tie: return .green + } + } + + private var label: String { + switch result { + case .playerWins: return "P" + case .bankerWins: return "B" + case .tie: return "T" + } + } + + var body: some View { + ZStack { + Circle() + .fill(color) + .frame(width: 22, height: 22) + + Circle() + .strokeBorder(Color.white.opacity(0.3), lineWidth: 1) + .frame(width: 22, height: 22) + + Text(label) + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + } + } +} + +#Preview { + ZStack { + Color(red: 0.0, green: 0.3, blue: 0.15) + .ignoresSafeArea() + + RoadMapView(results: [ + RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6), + RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7), + RoundResult(result: .tie, playerValue: 5, bankerValue: 5), + RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3), + RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 8) + ]) + .padding() + } +} + diff --git a/Baccarat/Views/SettingsView.swift b/Baccarat/Views/SettingsView.swift new file mode 100644 index 0000000..98972f8 --- /dev/null +++ b/Baccarat/Views/SettingsView.swift @@ -0,0 +1,384 @@ +// +// SettingsView.swift +// Baccarat +// +// Settings screen for game customization. +// + +import SwiftUI + +/// The settings screen for customizing game options. +struct SettingsView: View { + @Bindable var settings: GameSettings + @Environment(\.dismiss) private var dismiss + let onApplyChanges: () -> Void + + @State private var hasChanges = false + + var body: some View { + NavigationStack { + ZStack { + // Background + Color(red: 0.08, green: 0.12, blue: 0.08) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + // Table Limits Section (First!) + SettingsSection(title: "TABLE LIMITS", icon: "banknote") { + TableLimitsPicker(selection: $settings.tableLimits) + .onChange(of: settings.tableLimits) { _, _ in + hasChanges = true + } + } + + // Deck Settings Section + SettingsSection(title: "DECK SETTINGS", icon: "rectangle.portrait.on.rectangle.portrait") { + DeckCountPicker(selection: $settings.deckCount) + .onChange(of: settings.deckCount) { _, _ in + hasChanges = true + } + } + + // Starting Balance Section + SettingsSection(title: "STARTING BALANCE", icon: "dollarsign.circle") { + BalancePicker(balance: $settings.startingBalance) + .onChange(of: settings.startingBalance) { _, _ in + hasChanges = true + } + } + + // Display Settings Section + SettingsSection(title: "DISPLAY", icon: "eye") { + SettingsToggle( + title: "Show Cards Remaining", + subtitle: "Display deck counter at top", + isOn: $settings.showCardsRemaining + ) + + Divider() + .background(Color.white.opacity(0.1)) + + SettingsToggle( + title: "Show History", + subtitle: "Display result road map", + isOn: $settings.showHistory + ) + } + + // Animation Settings Section + SettingsSection(title: "ANIMATIONS", icon: "sparkles") { + SettingsToggle( + title: "Card Animations", + subtitle: "Animate dealing and flipping", + isOn: $settings.showAnimations + ) + + if settings.showAnimations { + Divider() + .background(Color.white.opacity(0.1)) + + SpeedPicker(speed: $settings.dealingSpeed) + } + } + + // Reset Button + Button { + settings.resetToDefaults() + hasChanges = true + } label: { + HStack { + Image(systemName: "arrow.counterclockwise") + Text("Reset to Defaults") + } + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.red.opacity(0.8)) + .padding() + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.red.opacity(0.1)) + ) + } + .padding(.horizontal) + .padding(.top, 8) + } + .padding(.vertical) + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(Color(red: 0.08, green: 0.12, blue: 0.08), for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { + settings.load() // Revert changes + dismiss() + } + .foregroundStyle(.white.opacity(0.7)) + } + + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + settings.save() + if hasChanges { + onApplyChanges() + } + dismiss() + } + .bold() + .foregroundStyle(.yellow) + } + } + } + } +} + +/// A settings section with a title and content. +struct SettingsSection: View { + let title: String + let icon: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.yellow.opacity(0.8)) + + Text(title) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .tracking(1) + .foregroundStyle(.white.opacity(0.6)) + } + .padding(.horizontal, 4) + + // Content card + VStack(spacing: 0) { + content + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.white.opacity(0.05)) + ) + } + .padding(.horizontal) + } +} + +/// Deck count picker with visual options. +struct DeckCountPicker: View { + @Binding var selection: DeckCount + + var body: some View { + VStack(spacing: 12) { + ForEach(DeckCount.allCases) { count in + Button { + selection = count + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(count.displayName) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + + Text(count.description) + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.5)) + } + + Spacer() + + if selection == count { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(.yellow) + } else { + Circle() + .strokeBorder(Color.white.opacity(0.3), lineWidth: 2) + .frame(width: 22, height: 22) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .fill(selection == count ? Color.yellow.opacity(0.1) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder( + selection == count ? Color.yellow.opacity(0.5) : Color.white.opacity(0.1), + lineWidth: 1 + ) + ) + } + .buttonStyle(.plain) + } + } + } +} + +/// Starting balance picker. +struct BalancePicker: View { + @Binding var balance: Int + + private let options = [1_000, 5_000, 10_000, 25_000, 50_000, 100_000] + + var body: some View { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 10) { + ForEach(options, id: \.self) { amount in + Button { + balance = amount + } label: { + Text("$\(amount / 1000)K") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(balance == amount ? .black : .white) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(balance == amount ? Color.yellow : Color.white.opacity(0.1)) + ) + } + .buttonStyle(.plain) + } + } + } +} + +/// A toggle setting row. +struct SettingsToggle: View { + let title: String + let subtitle: String + @Binding var isOn: Bool + + var body: some View { + Toggle(isOn: $isOn) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white) + + Text(subtitle) + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.5)) + } + } + .tint(.yellow) + } +} + +/// Animation speed picker. +struct SpeedPicker: View { + @Binding var speed: Double + + private let options: [(String, Double)] = [ + ("Fast", 0.5), + ("Normal", 1.0), + ("Slow", 2.0) + ] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Dealing Speed") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white) + + HStack(spacing: 8) { + ForEach(options, id: \.1) { option in + Button { + speed = option.1 + } label: { + Text(option.0) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(speed == option.1 ? .black : .white.opacity(0.7)) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background( + Capsule() + .fill(speed == option.1 ? Color.yellow : Color.white.opacity(0.1)) + ) + } + .buttonStyle(.plain) + } + } + } + } +} + +/// Table limits picker for min/max bets. +struct TableLimitsPicker: View { + @Binding var selection: TableLimits + + var body: some View { + VStack(spacing: 10) { + ForEach(TableLimits.allCases) { limit in + Button { + selection = limit + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(limit.displayName) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + + Text(limit.detailedDescription) + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.5)) + } + + Spacer() + + // Limits badge + Text(limit.description) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(selection == limit ? .black : .yellow) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Capsule() + .fill(selection == limit ? Color.yellow : Color.yellow.opacity(0.2)) + ) + + if selection == limit { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 20)) + .foregroundStyle(.yellow) + } else { + Circle() + .strokeBorder(Color.white.opacity(0.3), lineWidth: 2) + .frame(width: 20, height: 20) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 10) + .fill(selection == limit ? Color.yellow.opacity(0.1) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder( + selection == limit ? Color.yellow.opacity(0.5) : Color.white.opacity(0.1), + lineWidth: 1 + ) + ) + } + .buttonStyle(.plain) + } + } + } +} + +#Preview { + SettingsView(settings: GameSettings()) { } +} +