Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-16 17:26:42 -06:00
parent 20bd6aa97d
commit a898da7664
19 changed files with 3571 additions and 7 deletions

View File

@ -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 = "<group>";
};
@ -195,6 +208,8 @@
);
mainGroup = EAD890AE2EF1E9CE006DBA80;
minimizedProjectReferenceProxies = 1;
packageReferences = (
);
preferredProjectObjectVersion = 77;
productRefGroup = EAD890B82EF1E9CE006DBA80 /* Products */;
projectDirPath = "";

81
Baccarat/Agents.md Normal file
View File

@ -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 apps 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 taps 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`.
- Dont 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.

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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..<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)
visiblePlayerCards.append(playerThird)
playerCardsFaceUp.append(false)
try? await Task.sleep(for: shortDelay)
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)
visibleBankerCards.append(bankerThird)
bankerCardsFaceUp.append(false)
try? await Task.sleep(for: shortDelay)
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
// Calculate and apply payouts
var totalWinnings = 0
for bet in currentBets {
let payout = engine.calculatePayout(bet: bet, result: result)
totalWinnings += payout
// Return original bet if not a loss
if payout >= 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()
}
}

View File

@ -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
}
}

View File

@ -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)"
}
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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..<deckCount {
for suit in Suit.allCases {
for rank in Rank.allCases {
cards.append(Card(suit: suit, rank: rank))
}
}
}
// Fisher-Yates shuffle for true randomness
for i in stride(from: cards.count - 1, through: 1, by: -1) {
let j = Int.random(in: 0...i)
cards.swapAt(i, j)
}
}
/// Deals a single card from the top of the shoe.
/// - Returns: The dealt card, or nil if shoe is empty.
mutating func deal() -> 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..<min(count, cards.count) {
_ = cards.removeFirst()
}
}
}

View File

@ -0,0 +1,254 @@
//
// CardView.swift
// Baccarat
//
// Beautiful playing card view with flip animation.
//
import SwiftUI
/// A single playing card with elegant design and flip animation.
struct CardView: View {
let card: Card
let isFaceUp: Bool
let cardWidth: CGFloat
init(card: Card, isFaceUp: Bool = true, cardWidth: CGFloat = 70) {
self.card = card
self.isFaceUp = isFaceUp
self.cardWidth = cardWidth
}
private var cardHeight: CGFloat {
cardWidth * 1.4
}
var body: some View {
ZStack {
if isFaceUp {
CardFrontView(card: card, width: cardWidth, height: cardHeight)
} else {
CardBackView(width: cardWidth, height: cardHeight)
}
}
.rotation3DEffect(
.degrees(isFaceUp ? 0 : 180),
axis: (x: 0, y: 1, z: 0)
)
.animation(.spring(duration: 0.4, bounce: 0.2), value: isFaceUp)
}
}
/// The front face of a playing card showing rank and suit.
struct CardFrontView: View {
let card: Card
let width: CGFloat
let height: CGFloat
private var suitColor: Color {
card.suit.isRed ? .red : .black
}
var body: some View {
ZStack {
// Card background with subtle gradient
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
colors: [.white, Color(white: 0.96)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
// Card border
RoundedRectangle(cornerRadius: 8)
.strokeBorder(
LinearGradient(
colors: [Color(white: 0.8), Color(white: 0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1
)
// Card content
VStack {
// Top left corner
HStack {
VStack(spacing: 0) {
Text(card.rank.symbol)
.font(.system(size: width * 0.22, weight: .bold, design: .serif))
Text(card.suit.rawValue)
.font(.system(size: width * 0.18))
}
.foregroundStyle(suitColor)
Spacer()
}
Spacer()
// Center suit (large)
Text(card.suit.rawValue)
.font(.system(size: width * 0.5))
.foregroundStyle(suitColor)
Spacer()
// Bottom right corner (inverted)
HStack {
Spacer()
VStack(spacing: 0) {
Text(card.suit.rawValue)
.font(.system(size: width * 0.18))
Text(card.rank.symbol)
.font(.system(size: width * 0.22, weight: .bold, design: .serif))
}
.foregroundStyle(suitColor)
.rotationEffect(.degrees(180))
}
}
.padding(width * 0.08)
}
.frame(width: width, height: height)
.shadow(color: .black.opacity(0.2), radius: 4, x: 2, y: 2)
}
}
/// The back of a playing card with elegant pattern.
struct CardBackView: View {
let width: CGFloat
let height: CGFloat
var body: some View {
ZStack {
// Base
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
colors: [
Color(red: 0.6, green: 0.1, blue: 0.15),
Color(red: 0.4, green: 0.05, blue: 0.1)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
// Border
RoundedRectangle(cornerRadius: 8)
.strokeBorder(
LinearGradient(
colors: [
Color(red: 0.9, green: 0.7, blue: 0.4),
Color(red: 0.7, green: 0.5, blue: 0.2)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 2
)
// Inner pattern area
RoundedRectangle(cornerRadius: 4)
.fill(
LinearGradient(
colors: [
Color(red: 0.5, green: 0.08, blue: 0.12),
Color(red: 0.35, green: 0.04, blue: 0.08)
],
startPoint: .top,
endPoint: .bottom
)
)
.padding(width * 0.1)
// Diamond pattern overlay
DiamondPatternView()
.foregroundStyle(
Color(red: 0.9, green: 0.7, blue: 0.4).opacity(0.3)
)
.padding(width * 0.12)
.clipShape(RoundedRectangle(cornerRadius: 4))
// Center emblem
Circle()
.fill(
RadialGradient(
colors: [
Color(red: 0.9, green: 0.7, blue: 0.4),
Color(red: 0.7, green: 0.5, blue: 0.2)
],
center: .center,
startRadius: 0,
endRadius: width * 0.15
)
)
.frame(width: width * 0.3, height: width * 0.3)
// B for Baccarat
Text("B")
.font(.system(size: width * 0.18, weight: .bold, design: .serif))
.foregroundStyle(Color(red: 0.4, green: 0.05, blue: 0.1))
}
.frame(width: width, height: height)
.shadow(color: .black.opacity(0.3), radius: 4, x: 2, y: 2)
}
}
/// A decorative diamond pattern for card backs.
struct DiamondPatternView: View {
var body: some View {
Canvas { context, size in
let spacing: CGFloat = 12
let diamondSize: CGFloat = 6
for row in stride(from: 0, to: size.height, by: spacing) {
let offset = Int(row / spacing) % 2 == 0 ? 0 : spacing / 2
for col in stride(from: offset, to: size.width, by: spacing) {
let path = Path { p in
p.move(to: CGPoint(x: col, y: row - diamondSize / 2))
p.addLine(to: CGPoint(x: col + diamondSize / 2, y: row))
p.addLine(to: CGPoint(x: col, y: row + diamondSize / 2))
p.addLine(to: CGPoint(x: col - diamondSize / 2, y: row))
p.closeSubpath()
}
context.fill(path, with: .foreground)
}
}
}
}
}
/// A placeholder for an empty card slot.
struct CardPlaceholderView: View {
let width: CGFloat
private var height: CGFloat {
width * 1.4
}
var body: some View {
RoundedRectangle(cornerRadius: 8)
.strokeBorder(
Color.white.opacity(0.3),
style: StrokeStyle(lineWidth: 2, dash: [8, 4])
)
.frame(width: width, height: height)
}
}
#Preview {
ZStack {
Color(red: 0.0, green: 0.3, blue: 0.15)
.ignoresSafeArea()
HStack(spacing: 20) {
CardView(card: Card(suit: .hearts, rank: .ace), isFaceUp: true)
CardView(card: Card(suit: .spades, rank: .king), isFaceUp: true)
CardView(card: Card(suit: .diamonds, rank: .seven), isFaceUp: false)
}
}
}

View File

@ -0,0 +1,79 @@
//
// ChipSelectorView.swift
// Baccarat
//
// Horizontal chip selector for choosing bet denomination.
//
import SwiftUI
/// A horizontal scrollable chip selector.
/// Shows chips based on current balance - higher denomination chips unlock as you win more!
struct ChipSelectorView: View {
@Binding var selectedChip: ChipDenomination
let balance: Int
let maxBet: Int
init(selectedChip: Binding<ChipDenomination>, 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
)
}
}
}

View File

@ -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..<stripeCount {
let angle = Double(i) * (360.0 / Double(stripeCount)) * .pi / 180
let innerRadius = radius * 0.75
let outerRadius = radius * 0.98
let startX = center.x + cos(angle) * innerRadius
let startY = center.y + sin(angle) * innerRadius
let endX = center.x + cos(angle) * outerRadius
let endY = center.y + sin(angle) * outerRadius
var path = Path()
path.move(to: CGPoint(x: startX, y: startY))
path.addLine(to: CGPoint(x: endX, y: endY))
context.stroke(
path,
with: .color(stripeColor),
lineWidth: stripeWidth
)
}
}
}
}
/// A stack of chips showing a bet amount.
struct ChipStackView: View {
let amount: Int
let maxChips: Int = 5
private var chipBreakdown: [(ChipDenomination, Int)] {
var remaining = amount
var result: [(ChipDenomination, Int)] = []
for denom in ChipDenomination.allCases.reversed() {
let count = remaining / denom.rawValue
if count > 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)
}
}
}

View File

@ -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()
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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<Content: View>: 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()) { }
}