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; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
EAD891062EF1F51E006DBA80 /* Exceptions for "Baccarat" folder in "Baccarat" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Agents.md,
|
||||
);
|
||||
target = EAD890B62EF1E9CE006DBA80 /* Baccarat */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
EAD890B92EF1E9CE006DBA80 /* Baccarat */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
EAD891062EF1F51E006DBA80 /* Exceptions for "Baccarat" folder in "Baccarat" target */,
|
||||
);
|
||||
path = Baccarat;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@ -195,6 +208,8 @@
|
||||
);
|
||||
mainGroup = EAD890AE2EF1E9CE006DBA80;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = EAD890B82EF1E9CE006DBA80 /* Products */;
|
||||
projectDirPath = "";
|
||||
|
||||
81
Baccarat/Agents.md
Normal file
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 {
|
||||
var body: some View {
|
||||
VStack {
|
||||
Image(systemName: "globe")
|
||||
.imageScale(.large)
|
||||
.foregroundStyle(.tint)
|
||||
Text("Hello, world!")
|
||||
}
|
||||
.padding()
|
||||
GameTableView()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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