Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
20bd6aa97d
commit
a898da7664
@ -29,9 +29,22 @@
|
|||||||
EAD890CE2EF1E9CF006DBA80 /* BaccaratUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BaccaratUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
EAD890CE2EF1E9CF006DBA80 /* BaccaratUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BaccaratUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* 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 */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
EAD890B92EF1E9CE006DBA80 /* Baccarat */ = {
|
EAD890B92EF1E9CE006DBA80 /* Baccarat */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
EAD891062EF1F51E006DBA80 /* Exceptions for "Baccarat" folder in "Baccarat" target */,
|
||||||
|
);
|
||||||
path = Baccarat;
|
path = Baccarat;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -195,6 +208,8 @@
|
|||||||
);
|
);
|
||||||
mainGroup = EAD890AE2EF1E9CE006DBA80;
|
mainGroup = EAD890AE2EF1E9CE006DBA80;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
packageReferences = (
|
||||||
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = EAD890B82EF1E9CE006DBA80 /* Products */;
|
productRefGroup = EAD890B82EF1E9CE006DBA80 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
|
|||||||
81
Baccarat/Agents.md
Normal file
81
Baccarat/Agents.md
Normal 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 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.
|
||||||
|
|
||||||
@ -9,13 +9,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
GameTableView()
|
||||||
Image(systemName: "globe")
|
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
Text("Hello, world!")
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
196
Baccarat/Engine/BaccaratEngine.swift
Normal file
196
Baccarat/Engine/BaccaratEngine.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
392
Baccarat/Engine/GameState.swift
Normal file
392
Baccarat/Engine/GameState.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
58
Baccarat/Models/BetType.swift
Normal file
58
Baccarat/Models/BetType.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
83
Baccarat/Models/Card.swift
Normal file
83
Baccarat/Models/Card.swift
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
67
Baccarat/Models/GameResult.swift
Normal file
67
Baccarat/Models/GameResult.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
214
Baccarat/Models/GameSettings.swift
Normal file
214
Baccarat/Models/GameSettings.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
47
Baccarat/Models/Hand.swift
Normal file
47
Baccarat/Models/Hand.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
70
Baccarat/Models/Shoe.swift
Normal file
70
Baccarat/Models/Shoe.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
254
Baccarat/Views/CardView.swift
Normal file
254
Baccarat/Views/CardView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
79
Baccarat/Views/ChipSelectorView.swift
Normal file
79
Baccarat/Views/ChipSelectorView.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
291
Baccarat/Views/ChipView.swift
Normal file
291
Baccarat/Views/ChipView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
609
Baccarat/Views/GameTableView.swift
Normal file
609
Baccarat/Views/GameTableView.swift
Normal 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()
|
||||||
|
}
|
||||||
475
Baccarat/Views/MiniBaccaratTableView.swift
Normal file
475
Baccarat/Views/MiniBaccaratTableView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
163
Baccarat/Views/ResultBannerView.swift
Normal file
163
Baccarat/Views/ResultBannerView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
92
Baccarat/Views/RoadMapView.swift
Normal file
92
Baccarat/Views/RoadMapView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
384
Baccarat/Views/SettingsView.swift
Normal file
384
Baccarat/Views/SettingsView.swift
Normal 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()) { }
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user