Compare commits
10 Commits
000849cb70
...
8180b5ad47
| Author | SHA1 | Date | |
|---|---|---|---|
| 8180b5ad47 | |||
| 1df1796b12 | |||
| be9fc77605 | |||
| 2dd9ae020e | |||
| e4c43354f0 | |||
| 80df405a69 | |||
| 28eb33439e | |||
| 9081337dc1 | |||
| 9ad348daa1 | |||
| 9a22d133d5 |
@ -159,11 +159,9 @@ final class GameState {
|
|||||||
|
|
||||||
/// Handles data received from iCloud (e.g., after fresh install or from another device).
|
/// Handles data received from iCloud (e.g., after fresh install or from another device).
|
||||||
private func handleCloudDataReceived(_ cloudData: BaccaratGameData) {
|
private func handleCloudDataReceived(_ cloudData: BaccaratGameData) {
|
||||||
print("GameState: Received cloud data with \(cloudData.roundsPlayed) rounds")
|
|
||||||
|
|
||||||
// Only update if cloud has more progress than current state
|
// Only update if cloud has more progress than current state
|
||||||
guard cloudData.roundsPlayed > roundHistory.count else {
|
guard cloudData.roundsPlayed > roundHistory.count else {
|
||||||
print("GameState: Local data is newer, ignoring cloud data")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,8 +179,6 @@ final class GameState {
|
|||||||
bankerPair: saved.bankerPair
|
bankerPair: saved.bankerPair
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
print("GameState: Restored from cloud - \(cloudData.roundsPlayed) rounds, balance: \(cloudData.balance)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Persistence
|
// MARK: - Persistence
|
||||||
@ -208,8 +204,6 @@ final class GameState {
|
|||||||
bankerPair: saved.bankerPair
|
bankerPair: saved.bankerPair
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
print("GameState: Restored \(savedData.roundsPlayed) rounds, balance: \(savedData.balance)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves current game state to iCloud/local storage.
|
/// Saves current game state to iCloud/local storage.
|
||||||
|
|||||||
@ -295,8 +295,6 @@ final class GameSettings {
|
|||||||
if let volume = iCloudStore.object(forKey: Keys.soundVolume) as? Double {
|
if let volume = iCloudStore.object(forKey: Keys.soundVolume) as? Double {
|
||||||
self.soundVolume = Float(volume)
|
self.soundVolume = Float(volume)
|
||||||
}
|
}
|
||||||
|
|
||||||
print("GameSettings: Loaded from iCloud")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves settings to UserDefaults and iCloud.
|
/// Saves settings to UserDefaults and iCloud.
|
||||||
@ -327,7 +325,6 @@ final class GameSettings {
|
|||||||
iCloudStore.set(hapticsEnabled, forKey: Keys.hapticsEnabled)
|
iCloudStore.set(hapticsEnabled, forKey: Keys.hapticsEnabled)
|
||||||
iCloudStore.set(Double(soundVolume), forKey: Keys.soundVolume)
|
iCloudStore.set(Double(soundVolume), forKey: Keys.soundVolume)
|
||||||
iCloudStore.synchronize()
|
iCloudStore.synchronize()
|
||||||
print("GameSettings: Saved to iCloud")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1532,6 +1532,7 @@
|
|||||||
},
|
},
|
||||||
"Game Over" : {
|
"Game Over" : {
|
||||||
"comment" : "The title of the game over screen.",
|
"comment" : "The title of the game over screen.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -1555,6 +1556,7 @@
|
|||||||
},
|
},
|
||||||
"GAME OVER" : {
|
"GAME OVER" : {
|
||||||
"comment" : "The title of the game over screen.",
|
"comment" : "The title of the game over screen.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -2900,6 +2902,7 @@
|
|||||||
},
|
},
|
||||||
"Rounds Played" : {
|
"Rounds Played" : {
|
||||||
"comment" : "A label displayed next to the number of rounds played in the game over screen.",
|
"comment" : "A label displayed next to the number of rounds played in the game over screen.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
@ -145,18 +145,19 @@ struct GameTableView: View {
|
|||||||
// Main content area with optional sidebar
|
// Main content area with optional sidebar
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// Left side: Road map history grid
|
// Left side: Road map history grid
|
||||||
if settings.showHistory && !state.roundHistory.isEmpty {
|
if settings.showHistory {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||||
// Header with reading instructions
|
// Header with reading instructions
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
Text("HISTORY")
|
Text("HISTORY")
|
||||||
.font(.system(size: Design.BaseFontSize.small, weight: .bold, design: .rounded))
|
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
|
|
||||||
Text("↓ then →")
|
Text("↓ then →")
|
||||||
.font(.system(size: Design.BaseFontSize.xSmall, design: .rounded))
|
.font(.system(size: Design.BaseFontSize.large, design: .rounded))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
}
|
}
|
||||||
|
.padding(.leading, Design.Spacing.small)
|
||||||
.padding(.horizontal, Design.Spacing.small)
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
.padding(.top, Design.Spacing.small)
|
.padding(.top, Design.Spacing.small)
|
||||||
|
|
||||||
@ -285,7 +286,7 @@ struct GameTableView: View {
|
|||||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer2")
|
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer2")
|
||||||
|
|
||||||
// Road map history
|
// Road map history
|
||||||
if settings.showHistory && !state.roundHistory.isEmpty {
|
if settings.showHistory {
|
||||||
RoadMapView(results: state.recentResults)
|
RoadMapView(results: state.recentResults)
|
||||||
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
|
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|||||||
@ -1,189 +0,0 @@
|
|||||||
//
|
|
||||||
// GameOverView.swift
|
|
||||||
// Baccarat
|
|
||||||
//
|
|
||||||
// Game over screen shown when player runs out of money.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import CasinoKit
|
|
||||||
|
|
||||||
/// Game over screen shown when player runs out of money.
|
|
||||||
struct GameOverView: View {
|
|
||||||
let roundsPlayed: Int
|
|
||||||
let onPlayAgain: () -> Void
|
|
||||||
|
|
||||||
@State private var showContent = false
|
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
||||||
|
|
||||||
/// Maximum width for the modal card on iPad
|
|
||||||
private var maxModalWidth: CGFloat {
|
|
||||||
horizontalSizeClass == .regular ? CasinoDesign.Size.maxModalWidth : .infinity
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Scaled Font Sizes (Dynamic Type)
|
|
||||||
|
|
||||||
@ScaledMetric(relativeTo: .largeTitle) private var iconSize: CGFloat = Design.BaseFontSize.display
|
|
||||||
@ScaledMetric(relativeTo: .largeTitle) private var titleFontSize: CGFloat = Design.BaseFontSize.largeTitle
|
|
||||||
@ScaledMetric(relativeTo: .body) private var messageFontSize: CGFloat = Design.BaseFontSize.xLarge
|
|
||||||
@ScaledMetric(relativeTo: .body) private var statsFontSize: CGFloat = 17
|
|
||||||
@ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = Design.BaseFontSize.xLarge
|
|
||||||
|
|
||||||
// MARK: - Layout Constants
|
|
||||||
|
|
||||||
private let modalCornerRadius = Design.CornerRadius.xxxLarge
|
|
||||||
private let statsCornerRadius = Design.CornerRadius.large
|
|
||||||
private let cardPadding = Design.Spacing.xxxLarge
|
|
||||||
private let contentSpacing: CGFloat = 28
|
|
||||||
private let buttonHorizontalPadding: CGFloat = 48
|
|
||||||
private let buttonVerticalPadding: CGFloat = 18
|
|
||||||
|
|
||||||
// MARK: - Body
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
// Solid dark backdrop - fully opaque
|
|
||||||
Color.black
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
// Modal card
|
|
||||||
modalContent
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) {
|
|
||||||
showContent = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.accessibilityElement(children: .contain)
|
|
||||||
.accessibilityLabel(String(localized: "Game Over"))
|
|
||||||
.accessibilityAddTraits(.isModal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Views
|
|
||||||
|
|
||||||
private var modalContent: some View {
|
|
||||||
VStack(spacing: contentSpacing) {
|
|
||||||
// Broke icon
|
|
||||||
Image(systemName: "creditcard.trianglebadge.exclamationmark")
|
|
||||||
.font(.system(size: iconSize))
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
.symbolEffect(.pulse, options: .repeating)
|
|
||||||
|
|
||||||
// Title
|
|
||||||
Text("GAME OVER")
|
|
||||||
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
|
|
||||||
// Message
|
|
||||||
Text("You've run out of chips!")
|
|
||||||
.font(.system(size: messageFontSize, weight: .medium))
|
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
|
||||||
|
|
||||||
// Stats card
|
|
||||||
statsCard
|
|
||||||
|
|
||||||
// Play Again button
|
|
||||||
playAgainButton
|
|
||||||
}
|
|
||||||
.padding(cardPadding)
|
|
||||||
.background(modalBackground)
|
|
||||||
.shadow(color: .red.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXXLarge)
|
|
||||||
.frame(maxWidth: maxModalWidth)
|
|
||||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
|
||||||
.scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink)
|
|
||||||
.opacity(showContent ? 1.0 : 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var statsCard: some View {
|
|
||||||
VStack(spacing: Design.Spacing.medium) {
|
|
||||||
HStack {
|
|
||||||
Text("Rounds Played")
|
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
||||||
Spacer()
|
|
||||||
Text("\(roundsPlayed)")
|
|
||||||
.bold()
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(.system(size: statsFontSize))
|
|
||||||
.padding()
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: statsCornerRadius)
|
|
||||||
.fill(Color.white.opacity(Design.Opacity.subtle))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: statsCornerRadius)
|
|
||||||
.strokeBorder(Color.white.opacity(Design.Opacity.subtle), lineWidth: Design.LineWidth.thin)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.padding(.horizontal, Design.Spacing.xLarge)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var playAgainButton: some View {
|
|
||||||
Button {
|
|
||||||
onPlayAgain()
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: Design.Spacing.small) {
|
|
||||||
Image(systemName: "arrow.counterclockwise")
|
|
||||||
Text("Play Again")
|
|
||||||
}
|
|
||||||
.font(.system(size: buttonFontSize, weight: .bold))
|
|
||||||
.foregroundStyle(.black)
|
|
||||||
.padding(.horizontal, buttonHorizontalPadding)
|
|
||||||
.padding(.vertical, buttonVerticalPadding)
|
|
||||||
.background(
|
|
||||||
Capsule()
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusXLarge)
|
|
||||||
}
|
|
||||||
.padding(.top, Design.Spacing.medium)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var modalBackground: some View {
|
|
||||||
RoundedRectangle(cornerRadius: modalCornerRadius)
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.CasinoModal.backgroundLight, Color.CasinoModal.backgroundDark],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: modalCornerRadius)
|
|
||||||
.strokeBorder(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [
|
|
||||||
Color.red.opacity(Design.Opacity.medium),
|
|
||||||
Color.red.opacity(Design.Opacity.hint)
|
|
||||||
],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
),
|
|
||||||
lineWidth: Design.LineWidth.medium
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Previews
|
|
||||||
|
|
||||||
#Preview("Game Over") {
|
|
||||||
GameOverView(
|
|
||||||
roundsPlayed: 42,
|
|
||||||
onPlayAgain: {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Few Rounds") {
|
|
||||||
GameOverView(
|
|
||||||
roundsPlayed: 3,
|
|
||||||
onPlayAgain: {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -111,10 +111,10 @@ struct CardsDisplayArea: View {
|
|||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
Color.clear
|
Color.clear
|
||||||
.onAppear {
|
.onAppear {
|
||||||
containerWidth = geometry.size.width
|
containerWidth = geometry.size.width * 0.95
|
||||||
}
|
}
|
||||||
.onChange(of: geometry.size.width) { _, newWidth in
|
.onChange(of: geometry.size.width) { _, newWidth in
|
||||||
containerWidth = newWidth
|
containerWidth = newWidth * 0.95
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -82,6 +82,7 @@ struct RoadMapGridView: View {
|
|||||||
}
|
}
|
||||||
.padding(spacing)
|
.padding(spacing)
|
||||||
}
|
}
|
||||||
|
.padding(.leading, spacing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -411,91 +411,91 @@ final class BlackjackEngine {
|
|||||||
// 16 vs 10: Stand when TC ≥ 0 (basic strategy says Hit)
|
// 16 vs 10: Stand when TC ≥ 0 (basic strategy says Hit)
|
||||||
if playerValue == 16 && !isSoft && dealerValue == 10 {
|
if playerValue == 16 && !isSoft && dealerValue == 10 {
|
||||||
if tc >= 0 {
|
if tc >= 0 {
|
||||||
return String(localized: "Stand instead of Hit (TC \(tcDisplay), deck is neutral/rich)")
|
return String(localized: "Stand, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 15 vs 10: Stand when TC ≥ +4 (basic strategy says Hit/Surrender)
|
// 15 vs 10: Stand when TC ≥ +4 (basic strategy says Hit/Surrender)
|
||||||
if playerValue == 15 && !isSoft && dealerValue == 10 {
|
if playerValue == 15 && !isSoft && dealerValue == 10 {
|
||||||
if tc >= 4 {
|
if tc >= 4 {
|
||||||
return String(localized: "Stand instead of Hit (TC \(tcDisplay), deck is very rich)")
|
return String(localized: "Stand, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 12 vs 2: Stand when TC ≥ +3 (basic strategy says Hit)
|
// 12 vs 2: Stand when TC ≥ +3 (basic strategy says Hit)
|
||||||
if playerValue == 12 && !isSoft && dealerValue == 2 {
|
if playerValue == 12 && !isSoft && dealerValue == 2 {
|
||||||
if tc >= 3 {
|
if tc >= 3 {
|
||||||
return String(localized: "Stand instead of Hit (TC \(tcDisplay), dealer likely to bust)")
|
return String(localized: "Stand, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 12 vs 3: Stand when TC ≥ +2 (basic strategy says Hit)
|
// 12 vs 3: Stand when TC ≥ +2 (basic strategy says Hit)
|
||||||
if playerValue == 12 && !isSoft && dealerValue == 3 {
|
if playerValue == 12 && !isSoft && dealerValue == 3 {
|
||||||
if tc >= 2 {
|
if tc >= 2 {
|
||||||
return String(localized: "Stand instead of Hit (TC \(tcDisplay), dealer likely to bust)")
|
return String(localized: "Stand, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 12 vs 4: Hit when TC < 0 (basic strategy says Stand)
|
// 12 vs 4: Hit when TC < 0 (basic strategy says Stand)
|
||||||
if playerValue == 12 && !isSoft && dealerValue == 4 {
|
if playerValue == 12 && !isSoft && dealerValue == 4 {
|
||||||
if tc < 0 {
|
if tc < 0 {
|
||||||
return String(localized: "Hit instead of Stand (TC \(tcDisplay), deck is poor)")
|
return String(localized: "Hit, not Stand (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 13 vs 2: Hit when TC < -1 (basic strategy says Stand)
|
// 13 vs 2: Hit when TC < -1 (basic strategy says Stand)
|
||||||
if playerValue == 13 && !isSoft && dealerValue == 2 {
|
if playerValue == 13 && !isSoft && dealerValue == 2 {
|
||||||
if tc < -1 {
|
if tc < -1 {
|
||||||
return String(localized: "Hit instead of Stand (TC \(tcDisplay), deck is very poor)")
|
return String(localized: "Hit, not Stand (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 16 vs 9: Stand when TC ≥ +5 (basic strategy says Hit)
|
// 16 vs 9: Stand when TC ≥ +5 (basic strategy says Hit)
|
||||||
if playerValue == 16 && !isSoft && dealerValue == 9 {
|
if playerValue == 16 && !isSoft && dealerValue == 9 {
|
||||||
if tc >= 5 {
|
if tc >= 5 {
|
||||||
return String(localized: "Stand instead of Hit (TC \(tcDisplay), deck is extremely rich)")
|
return String(localized: "Stand, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10 vs 10: Double when TC ≥ +4 (basic strategy says Hit)
|
// 10 vs 10: Double when TC ≥ +4 (basic strategy says Hit)
|
||||||
if playerValue == 10 && !isSoft && playerHand.cards.count == 2 && dealerValue == 10 {
|
if playerValue == 10 && !isSoft && playerHand.cards.count == 2 && dealerValue == 10 {
|
||||||
if tc >= 4 {
|
if tc >= 4 {
|
||||||
return String(localized: "Double instead of Hit (TC \(tcDisplay), high cards favor you)")
|
return String(localized: "Double, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10 vs Ace: Double when TC ≥ +4 (basic strategy says Hit)
|
// 10 vs Ace: Double when TC ≥ +4 (basic strategy says Hit)
|
||||||
if playerValue == 10 && !isSoft && playerHand.cards.count == 2 && dealerValue == 1 {
|
if playerValue == 10 && !isSoft && playerHand.cards.count == 2 && dealerValue == 1 {
|
||||||
if tc >= 4 {
|
if tc >= 4 {
|
||||||
return String(localized: "Double instead of Hit (TC \(tcDisplay), high cards favor you)")
|
return String(localized: "Double, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9 vs 2: Double when TC ≥ +1 (basic strategy says Hit)
|
// 9 vs 2: Double when TC ≥ +1 (basic strategy says Hit)
|
||||||
if playerValue == 9 && !isSoft && playerHand.cards.count == 2 && dealerValue == 2 {
|
if playerValue == 9 && !isSoft && playerHand.cards.count == 2 && dealerValue == 2 {
|
||||||
if tc >= 1 {
|
if tc >= 1 {
|
||||||
return String(localized: "Double instead of Hit (TC \(tcDisplay), slight edge to double)")
|
return String(localized: "Double, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9 vs 7: Double when TC ≥ +3 (basic strategy says Hit)
|
// 9 vs 7: Double when TC ≥ +3 (basic strategy says Hit)
|
||||||
if playerValue == 9 && !isSoft && playerHand.cards.count == 2 && dealerValue == 7 {
|
if playerValue == 9 && !isSoft && playerHand.cards.count == 2 && dealerValue == 7 {
|
||||||
if tc >= 3 {
|
if tc >= 3 {
|
||||||
return String(localized: "Double instead of Hit (TC \(tcDisplay), deck favors doubling)")
|
return String(localized: "Double, not Hit (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pair of 10s vs 5: Split when TC ≥ +5 (basic strategy says Stand)
|
// Pair of 10s vs 5: Split when TC ≥ +5 (basic strategy says Stand)
|
||||||
if playerHand.canSplit && playerHand.cards[0].blackjackValue == 10 && dealerValue == 5 {
|
if playerHand.canSplit && playerHand.cards[0].blackjackValue == 10 && dealerValue == 5 {
|
||||||
if tc >= 5 {
|
if tc >= 5 {
|
||||||
return String(localized: "Split instead of Stand (TC \(tcDisplay), dealer very likely to bust)")
|
return String(localized: "Split, not Stand (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pair of 10s vs 6: Split when TC ≥ +4 (basic strategy says Stand)
|
// Pair of 10s vs 6: Split when TC ≥ +4 (basic strategy says Stand)
|
||||||
if playerHand.canSplit && playerHand.cards[0].blackjackValue == 10 && dealerValue == 6 {
|
if playerHand.canSplit && playerHand.cards[0].blackjackValue == 10 && dealerValue == 6 {
|
||||||
if tc >= 4 {
|
if tc >= 4 {
|
||||||
return String(localized: "Split instead of Stand (TC \(tcDisplay), dealer very likely to bust)")
|
return String(localized: "Split, not Stand (TC \(tcDisplay))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,23 @@ final class GameState {
|
|||||||
/// Insurance bet amount.
|
/// Insurance bet amount.
|
||||||
var insuranceBet: Int = 0
|
var insuranceBet: Int = 0
|
||||||
|
|
||||||
|
// MARK: - Side Bets
|
||||||
|
|
||||||
|
/// Perfect Pairs side bet amount.
|
||||||
|
var perfectPairsBet: Int = 0
|
||||||
|
|
||||||
|
/// 21+3 side bet amount.
|
||||||
|
var twentyOnePlusThreeBet: Int = 0
|
||||||
|
|
||||||
|
/// Result of Perfect Pairs bet (set after deal).
|
||||||
|
var perfectPairsResult: PerfectPairsResult?
|
||||||
|
|
||||||
|
/// Result of 21+3 bet (set after deal).
|
||||||
|
var twentyOnePlusThreeResult: TwentyOnePlusThreeResult?
|
||||||
|
|
||||||
|
/// Whether to show side bet toast notifications.
|
||||||
|
var showSideBetToasts: Bool = false
|
||||||
|
|
||||||
/// Whether a reshuffle notification should be shown.
|
/// Whether a reshuffle notification should be shown.
|
||||||
var showReshuffleNotification: Bool = false
|
var showReshuffleNotification: Bool = false
|
||||||
|
|
||||||
@ -105,8 +122,57 @@ final class GameState {
|
|||||||
|
|
||||||
/// Whether player can place a bet.
|
/// Whether player can place a bet.
|
||||||
/// True if in betting phase, have balance, and haven't hit max bet.
|
/// True if in betting phase, have balance, and haven't hit max bet.
|
||||||
var canBet: Bool {
|
/// Whether in betting phase (matches Baccarat's canPlaceBet).
|
||||||
currentPhase == .betting && balance > 0 && currentBet < settings.maxBet
|
var canPlaceBet: Bool {
|
||||||
|
currentPhase == .betting
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the main bet can accept more chips of the given amount.
|
||||||
|
/// Matches Baccarat's canAddToBet pattern.
|
||||||
|
func canAddToMainBet(amount: Int) -> Bool {
|
||||||
|
currentBet + amount <= settings.maxBet
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a specific side bet can accept more chips of the given amount.
|
||||||
|
/// Matches Baccarat's canAddToBet pattern.
|
||||||
|
func canAddToSideBet(type: SideBetType, amount: Int) -> Bool {
|
||||||
|
guard settings.sideBetsEnabled else { return false }
|
||||||
|
let currentAmount: Int
|
||||||
|
switch type {
|
||||||
|
case .perfectPairs:
|
||||||
|
currentAmount = perfectPairsBet
|
||||||
|
case .twentyOnePlusThree:
|
||||||
|
currentAmount = twentyOnePlusThreeBet
|
||||||
|
}
|
||||||
|
return currentAmount + amount <= settings.maxBet
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a bet type is at max.
|
||||||
|
func isAtMax(main: Bool = false, sideType: SideBetType? = nil) -> Bool {
|
||||||
|
if main {
|
||||||
|
return currentBet >= settings.maxBet
|
||||||
|
}
|
||||||
|
if let type = sideType {
|
||||||
|
switch type {
|
||||||
|
case .perfectPairs:
|
||||||
|
return perfectPairsBet >= settings.maxBet
|
||||||
|
case .twentyOnePlusThree:
|
||||||
|
return twentyOnePlusThreeBet >= settings.maxBet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The minimum bet level across all active bet types.
|
||||||
|
/// Used by chip selector to determine if chips should be enabled.
|
||||||
|
/// Returns the smallest bet so chips stay enabled if ANY bet type can accept more.
|
||||||
|
var minBetForChipSelector: Int {
|
||||||
|
if settings.sideBetsEnabled {
|
||||||
|
// Return the minimum of all bet types so chips stay enabled if any can be increased
|
||||||
|
return min(currentBet, perfectPairsBet, twentyOnePlusThreeBet)
|
||||||
|
} else {
|
||||||
|
return currentBet
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the current hand can hit.
|
/// Whether the current hand can hit.
|
||||||
@ -315,24 +381,49 @@ final class GameState {
|
|||||||
|
|
||||||
// MARK: - Betting
|
// MARK: - Betting
|
||||||
|
|
||||||
/// Places a bet.
|
/// Places a main bet.
|
||||||
|
/// Places a main bet. Matches Baccarat's placeBet pattern.
|
||||||
func placeBet(amount: Int) {
|
func placeBet(amount: Int) {
|
||||||
guard canBet else { return }
|
guard canPlaceBet else { return }
|
||||||
guard currentBet + amount <= settings.maxBet else { return }
|
|
||||||
guard balance >= amount else { return }
|
guard balance >= amount else { return }
|
||||||
|
guard canAddToMainBet(amount: amount) else { return }
|
||||||
|
|
||||||
currentBet += amount
|
currentBet += amount
|
||||||
balance -= amount
|
balance -= amount
|
||||||
sound.play(.chipPlace)
|
sound.play(.chipPlace)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears the current bet.
|
/// Places a side bet (Perfect Pairs or 21+3).
|
||||||
func clearBet() {
|
/// Matches Baccarat's placeBet pattern for side bets.
|
||||||
balance += currentBet
|
func placeSideBet(type: SideBetType, amount: Int) {
|
||||||
currentBet = 0
|
guard canPlaceBet else { return }
|
||||||
|
guard balance >= amount else { return }
|
||||||
|
guard canAddToSideBet(type: type, amount: amount) else { return }
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .perfectPairs:
|
||||||
|
perfectPairsBet += amount
|
||||||
|
case .twentyOnePlusThree:
|
||||||
|
twentyOnePlusThreeBet += amount
|
||||||
|
}
|
||||||
|
balance -= amount
|
||||||
sound.play(.chipPlace)
|
sound.play(.chipPlace)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears all bets (main and side bets).
|
||||||
|
func clearBet() {
|
||||||
|
balance += currentBet + perfectPairsBet + twentyOnePlusThreeBet
|
||||||
|
currentBet = 0
|
||||||
|
perfectPairsBet = 0
|
||||||
|
twentyOnePlusThreeBet = 0
|
||||||
|
sound.play(.chipPlace)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total amount bet (main + side bets).
|
||||||
|
var totalBetAmount: Int {
|
||||||
|
currentBet + perfectPairsBet + twentyOnePlusThreeBet
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Dealing
|
// MARK: - Dealing
|
||||||
|
|
||||||
/// Whether the player can deal (betting phase with valid bet).
|
/// Whether the player can deal (betting phase with valid bet).
|
||||||
@ -360,6 +451,8 @@ final class GameState {
|
|||||||
dealerHand = BlackjackHand()
|
dealerHand = BlackjackHand()
|
||||||
activeHandIndex = 0
|
activeHandIndex = 0
|
||||||
insuranceBet = 0
|
insuranceBet = 0
|
||||||
|
perfectPairsResult = nil
|
||||||
|
twentyOnePlusThreeResult = nil
|
||||||
|
|
||||||
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
|
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
|
||||||
|
|
||||||
@ -381,6 +474,9 @@ final class GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Evaluate side bets after initial cards are dealt
|
||||||
|
evaluateSideBets()
|
||||||
|
|
||||||
// Check for insurance offer (only in American style with hole card)
|
// Check for insurance offer (only in American style with hole card)
|
||||||
if !settings.noHoleCard, let upCard = dealerUpCard, engine.shouldOfferInsurance(dealerUpCard: upCard) {
|
if !settings.noHoleCard, let upCard = dealerUpCard, engine.shouldOfferInsurance(dealerUpCard: upCard) {
|
||||||
currentPhase = .insurance
|
currentPhase = .insurance
|
||||||
@ -652,6 +748,66 @@ final class GameState {
|
|||||||
await completeRound()
|
await completeRound()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Side Bets
|
||||||
|
|
||||||
|
/// Evaluates side bets based on the initial deal.
|
||||||
|
private func evaluateSideBets() {
|
||||||
|
guard settings.sideBetsEnabled else { return }
|
||||||
|
|
||||||
|
let playerCards = playerHands[0].cards
|
||||||
|
guard playerCards.count >= 2 else { return }
|
||||||
|
|
||||||
|
// Evaluate Perfect Pairs
|
||||||
|
if perfectPairsBet > 0 {
|
||||||
|
perfectPairsResult = SideBetEvaluator.evaluatePerfectPairs(
|
||||||
|
card1: playerCards[0],
|
||||||
|
card2: playerCards[1]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate 21+3 (requires dealer upcard)
|
||||||
|
if twentyOnePlusThreeBet > 0, let dealerUpCard = dealerUpCard {
|
||||||
|
twentyOnePlusThreeResult = SideBetEvaluator.evaluateTwentyOnePlusThree(
|
||||||
|
playerCard1: playerCards[0],
|
||||||
|
playerCard2: playerCards[1],
|
||||||
|
dealerUpcard: dealerUpCard
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show toast notifications if any side bets were placed
|
||||||
|
if perfectPairsBet > 0 || twentyOnePlusThreeBet > 0 {
|
||||||
|
showSideBetToasts = true
|
||||||
|
|
||||||
|
// Play sound for side bet result
|
||||||
|
let ppWon = perfectPairsResult?.isWin ?? false
|
||||||
|
let topWon = twentyOnePlusThreeResult?.isWin ?? false
|
||||||
|
if ppWon || topWon {
|
||||||
|
sound.play(.win)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-hide toasts after delay
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(3))
|
||||||
|
showSideBetToasts = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates winnings from side bets.
|
||||||
|
var sideBetWinnings: Int {
|
||||||
|
var winnings = 0
|
||||||
|
|
||||||
|
if let ppResult = perfectPairsResult, ppResult.isWin {
|
||||||
|
winnings += perfectPairsBet * ppResult.payout
|
||||||
|
}
|
||||||
|
|
||||||
|
if let topResult = twentyOnePlusThreeResult, topResult.isWin {
|
||||||
|
winnings += twentyOnePlusThreeBet * topResult.payout
|
||||||
|
}
|
||||||
|
|
||||||
|
return winnings
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Round Completion
|
// MARK: - Round Completion
|
||||||
|
|
||||||
/// Completes the round and calculates payouts.
|
/// Completes the round and calculates payouts.
|
||||||
@ -712,6 +868,17 @@ final class GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate side bet results
|
||||||
|
let sideBetsTotal = perfectPairsBet + twentyOnePlusThreeBet
|
||||||
|
let sideBetsWon = sideBetWinnings
|
||||||
|
if sideBetsWon > 0 {
|
||||||
|
balance += sideBetsWon + sideBetsTotal // Return bet + winnings
|
||||||
|
roundWinnings += sideBetsWon
|
||||||
|
} else if sideBetsTotal > 0 {
|
||||||
|
// Side bets lost - account for the loss in round winnings
|
||||||
|
roundWinnings -= sideBetsTotal
|
||||||
|
}
|
||||||
|
|
||||||
// Update statistics
|
// Update statistics
|
||||||
totalWinnings += roundWinnings
|
totalWinnings += roundWinnings
|
||||||
if roundWinnings > biggestWin {
|
if roundWinnings > biggestWin {
|
||||||
@ -727,13 +894,33 @@ final class GameState {
|
|||||||
bustCount += 1
|
bustCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create round result with all hand results and per-hand winnings
|
// Create round result with all hand results, per-hand winnings, and side bets
|
||||||
let allHandResults = playerHands.map { $0.result ?? .lose }
|
let allHandResults = playerHands.map { $0.result ?? .lose }
|
||||||
|
|
||||||
|
// Calculate individual side bet winnings
|
||||||
|
let ppWinnings: Int
|
||||||
|
if let ppResult = perfectPairsResult, perfectPairsBet > 0 {
|
||||||
|
ppWinnings = ppResult.isWin ? perfectPairsBet * ppResult.payout : -perfectPairsBet
|
||||||
|
} else {
|
||||||
|
ppWinnings = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let topWinnings: Int
|
||||||
|
if let topResult = twentyOnePlusThreeResult, twentyOnePlusThreeBet > 0 {
|
||||||
|
topWinnings = topResult.isWin ? twentyOnePlusThreeBet * topResult.payout : -twentyOnePlusThreeBet
|
||||||
|
} else {
|
||||||
|
topWinnings = 0
|
||||||
|
}
|
||||||
|
|
||||||
lastRoundResult = RoundResult(
|
lastRoundResult = RoundResult(
|
||||||
handResults: allHandResults,
|
handResults: allHandResults,
|
||||||
handWinnings: perHandWinnings,
|
handWinnings: perHandWinnings,
|
||||||
insuranceResult: insResult,
|
insuranceResult: insResult,
|
||||||
insuranceWinnings: insWinnings,
|
insuranceWinnings: insWinnings,
|
||||||
|
perfectPairsResult: perfectPairsBet > 0 ? perfectPairsResult : nil,
|
||||||
|
perfectPairsWinnings: ppWinnings,
|
||||||
|
twentyOnePlusThreeResult: twentyOnePlusThreeBet > 0 ? twentyOnePlusThreeResult : nil,
|
||||||
|
twentyOnePlusThreeWinnings: topWinnings,
|
||||||
totalWinnings: roundWinnings,
|
totalWinnings: roundWinnings,
|
||||||
wasBlackjack: wasBlackjack
|
wasBlackjack: wasBlackjack
|
||||||
)
|
)
|
||||||
@ -782,6 +969,13 @@ final class GameState {
|
|||||||
// Reset bets
|
// Reset bets
|
||||||
currentBet = 0
|
currentBet = 0
|
||||||
insuranceBet = 0
|
insuranceBet = 0
|
||||||
|
perfectPairsBet = 0
|
||||||
|
twentyOnePlusThreeBet = 0
|
||||||
|
|
||||||
|
// Reset side bet results
|
||||||
|
perfectPairsResult = nil
|
||||||
|
twentyOnePlusThreeResult = nil
|
||||||
|
showSideBetToasts = false
|
||||||
|
|
||||||
// Reset UI state
|
// Reset UI state
|
||||||
showResultBanner = false
|
showResultBanner = false
|
||||||
|
|||||||
@ -76,6 +76,17 @@ struct RoundResult: Equatable {
|
|||||||
let totalWinnings: Int
|
let totalWinnings: Int
|
||||||
let wasBlackjack: Bool
|
let wasBlackjack: Bool
|
||||||
|
|
||||||
|
// MARK: - Side Bets
|
||||||
|
|
||||||
|
/// Perfect Pairs result (nil if no bet placed)
|
||||||
|
let perfectPairsResult: PerfectPairsResult?
|
||||||
|
/// Perfect Pairs winnings (positive if won, negative if lost)
|
||||||
|
let perfectPairsWinnings: Int
|
||||||
|
/// 21+3 result (nil if no bet placed)
|
||||||
|
let twentyOnePlusThreeResult: TwentyOnePlusThreeResult?
|
||||||
|
/// 21+3 winnings (positive if won, negative if lost)
|
||||||
|
let twentyOnePlusThreeWinnings: Int
|
||||||
|
|
||||||
/// Convenience initializer without per-hand winnings (backwards compatibility)
|
/// Convenience initializer without per-hand winnings (backwards compatibility)
|
||||||
init(handResults: [HandResult], insuranceResult: HandResult?, totalWinnings: Int, wasBlackjack: Bool) {
|
init(handResults: [HandResult], insuranceResult: HandResult?, totalWinnings: Int, wasBlackjack: Bool) {
|
||||||
self.handResults = handResults
|
self.handResults = handResults
|
||||||
@ -84,6 +95,10 @@ struct RoundResult: Equatable {
|
|||||||
self.insuranceWinnings = 0
|
self.insuranceWinnings = 0
|
||||||
self.totalWinnings = totalWinnings
|
self.totalWinnings = totalWinnings
|
||||||
self.wasBlackjack = wasBlackjack
|
self.wasBlackjack = wasBlackjack
|
||||||
|
self.perfectPairsResult = nil
|
||||||
|
self.perfectPairsWinnings = 0
|
||||||
|
self.twentyOnePlusThreeResult = nil
|
||||||
|
self.twentyOnePlusThreeWinnings = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full initializer with per-hand winnings
|
/// Full initializer with per-hand winnings
|
||||||
@ -94,6 +109,35 @@ struct RoundResult: Equatable {
|
|||||||
self.insuranceWinnings = insuranceWinnings
|
self.insuranceWinnings = insuranceWinnings
|
||||||
self.totalWinnings = totalWinnings
|
self.totalWinnings = totalWinnings
|
||||||
self.wasBlackjack = wasBlackjack
|
self.wasBlackjack = wasBlackjack
|
||||||
|
self.perfectPairsResult = nil
|
||||||
|
self.perfectPairsWinnings = 0
|
||||||
|
self.twentyOnePlusThreeResult = nil
|
||||||
|
self.twentyOnePlusThreeWinnings = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full initializer with side bets
|
||||||
|
init(
|
||||||
|
handResults: [HandResult],
|
||||||
|
handWinnings: [Int],
|
||||||
|
insuranceResult: HandResult?,
|
||||||
|
insuranceWinnings: Int,
|
||||||
|
perfectPairsResult: PerfectPairsResult?,
|
||||||
|
perfectPairsWinnings: Int,
|
||||||
|
twentyOnePlusThreeResult: TwentyOnePlusThreeResult?,
|
||||||
|
twentyOnePlusThreeWinnings: Int,
|
||||||
|
totalWinnings: Int,
|
||||||
|
wasBlackjack: Bool
|
||||||
|
) {
|
||||||
|
self.handResults = handResults
|
||||||
|
self.handWinnings = handWinnings
|
||||||
|
self.insuranceResult = insuranceResult
|
||||||
|
self.insuranceWinnings = insuranceWinnings
|
||||||
|
self.perfectPairsResult = perfectPairsResult
|
||||||
|
self.perfectPairsWinnings = perfectPairsWinnings
|
||||||
|
self.twentyOnePlusThreeResult = twentyOnePlusThreeResult
|
||||||
|
self.twentyOnePlusThreeWinnings = twentyOnePlusThreeWinnings
|
||||||
|
self.totalWinnings = totalWinnings
|
||||||
|
self.wasBlackjack = wasBlackjack
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The main/best result for display purposes (first hand, or best if split)
|
/// The main/best result for display purposes (first hand, or best if split)
|
||||||
@ -112,6 +156,11 @@ struct RoundResult: Equatable {
|
|||||||
handResults.count > 1
|
handResults.count > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this round had any side bets
|
||||||
|
var hadSideBets: Bool {
|
||||||
|
perfectPairsResult != nil || twentyOnePlusThreeResult != nil
|
||||||
|
}
|
||||||
|
|
||||||
/// Legacy accessor for backwards compatibility
|
/// Legacy accessor for backwards compatibility
|
||||||
var splitHandResult: HandResult? {
|
var splitHandResult: HandResult? {
|
||||||
handResults.count > 1 ? handResults[1] : nil
|
handResults.count > 1 ? handResults[1] : nil
|
||||||
|
|||||||
@ -132,6 +132,11 @@ final class GameSettings {
|
|||||||
/// Speed of card dealing (1.0 = normal)
|
/// Speed of card dealing (1.0 = normal)
|
||||||
var dealingSpeed: Double = 1.0 { didSet { save() } }
|
var dealingSpeed: Double = 1.0 { didSet { save() } }
|
||||||
|
|
||||||
|
// MARK: - Side Bets
|
||||||
|
|
||||||
|
/// Whether side bets (Perfect Pairs, 21+3) are enabled.
|
||||||
|
var sideBetsEnabled: Bool = false { didSet { save() } }
|
||||||
|
|
||||||
// MARK: - Display Settings
|
// MARK: - Display Settings
|
||||||
|
|
||||||
/// Whether to show the cards remaining indicator.
|
/// Whether to show the cards remaining indicator.
|
||||||
@ -232,6 +237,7 @@ final class GameSettings {
|
|||||||
self.noHoleCard = data.noHoleCard
|
self.noHoleCard = data.noHoleCard
|
||||||
self.blackjackPayout = data.blackjackPayout
|
self.blackjackPayout = data.blackjackPayout
|
||||||
self.insuranceAllowed = data.insuranceAllowed
|
self.insuranceAllowed = data.insuranceAllowed
|
||||||
|
self.sideBetsEnabled = data.sideBetsEnabled
|
||||||
self.showAnimations = data.showAnimations
|
self.showAnimations = data.showAnimations
|
||||||
self.dealingSpeed = data.dealingSpeed
|
self.dealingSpeed = data.dealingSpeed
|
||||||
self.showCardsRemaining = data.showCardsRemaining
|
self.showCardsRemaining = data.showCardsRemaining
|
||||||
@ -257,6 +263,7 @@ final class GameSettings {
|
|||||||
noHoleCard: noHoleCard,
|
noHoleCard: noHoleCard,
|
||||||
blackjackPayout: blackjackPayout,
|
blackjackPayout: blackjackPayout,
|
||||||
insuranceAllowed: insuranceAllowed,
|
insuranceAllowed: insuranceAllowed,
|
||||||
|
sideBetsEnabled: sideBetsEnabled,
|
||||||
showAnimations: showAnimations,
|
showAnimations: showAnimations,
|
||||||
dealingSpeed: dealingSpeed,
|
dealingSpeed: dealingSpeed,
|
||||||
showCardsRemaining: showCardsRemaining,
|
showCardsRemaining: showCardsRemaining,
|
||||||
@ -283,6 +290,7 @@ final class GameSettings {
|
|||||||
noHoleCard = false
|
noHoleCard = false
|
||||||
blackjackPayout = 1.5
|
blackjackPayout = 1.5
|
||||||
insuranceAllowed = true
|
insuranceAllowed = true
|
||||||
|
sideBetsEnabled = false
|
||||||
showAnimations = true
|
showAnimations = true
|
||||||
dealingSpeed = 1.0
|
dealingSpeed = 1.0
|
||||||
showCardsRemaining = true
|
showCardsRemaining = true
|
||||||
|
|||||||
@ -117,10 +117,11 @@ struct BlackjackHand: Identifiable, Equatable {
|
|||||||
// MARK: - Card Value Extension
|
// MARK: - Card Value Extension
|
||||||
|
|
||||||
extension Card {
|
extension Card {
|
||||||
/// The blackjack value of this card (Ace = 1 or 11, face cards = 10).
|
/// The blackjack value of this card for display purposes (Ace = 11, face cards = 10).
|
||||||
|
/// Note: Hand calculation handles ace flexibility (1 or 11).
|
||||||
var blackjackValue: Int {
|
var blackjackValue: Int {
|
||||||
switch rank {
|
switch rank {
|
||||||
case .ace: return 1 // Or 11, handled by hand calculation
|
case .ace: return 11 // Show as 11 for display; hand calculation handles soft/hard
|
||||||
case .two: return 2
|
case .two: return 2
|
||||||
case .three: return 3
|
case .three: return 3
|
||||||
case .four: return 4
|
case .four: return 4
|
||||||
|
|||||||
229
Blackjack/Blackjack/Models/SideBet.swift
Normal file
229
Blackjack/Blackjack/Models/SideBet.swift
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
//
|
||||||
|
// SideBet.swift
|
||||||
|
// Blackjack
|
||||||
|
//
|
||||||
|
// Side bet types and evaluation for Perfect Pairs and 21+3.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CasinoKit
|
||||||
|
|
||||||
|
// MARK: - Side Bet Types
|
||||||
|
|
||||||
|
/// Available side bet types in Blackjack.
|
||||||
|
enum SideBetType: String, CaseIterable, Identifiable {
|
||||||
|
case perfectPairs = "perfectPairs"
|
||||||
|
case twentyOnePlusThree = "21+3"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .perfectPairs: return String(localized: "Perfect Pairs")
|
||||||
|
case .twentyOnePlusThree: return String(localized: "21+3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var shortName: String {
|
||||||
|
switch self {
|
||||||
|
case .perfectPairs: return "PP"
|
||||||
|
case .twentyOnePlusThree: return "21+3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .perfectPairs:
|
||||||
|
return String(localized: "Bet on your first two cards forming a pair")
|
||||||
|
case .twentyOnePlusThree:
|
||||||
|
return String(localized: "Poker hand from your first two cards + dealer's upcard")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Perfect Pairs Results
|
||||||
|
|
||||||
|
/// Perfect Pairs side bet outcomes.
|
||||||
|
enum PerfectPairsResult: CaseIterable {
|
||||||
|
case perfectPair // Same rank AND same suit (e.g., K♠ K♠)
|
||||||
|
case coloredPair // Same rank, same color, different suit (e.g., K♠ K♣)
|
||||||
|
case mixedPair // Same rank, different color (e.g., K♠ K♥)
|
||||||
|
case noPair // No pair
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .perfectPair: return String(localized: "Perfect Pair")
|
||||||
|
case .coloredPair: return String(localized: "Colored Pair")
|
||||||
|
case .mixedPair: return String(localized: "Mixed Pair")
|
||||||
|
case .noPair: return String(localized: "No Pair")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payout multiplier (e.g., 25 means 25:1)
|
||||||
|
var payout: Int {
|
||||||
|
switch self {
|
||||||
|
case .perfectPair: return 25
|
||||||
|
case .coloredPair: return 12
|
||||||
|
case .mixedPair: return 6
|
||||||
|
case .noPair: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isWin: Bool {
|
||||||
|
self != .noPair
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 21+3 Results
|
||||||
|
|
||||||
|
/// 21+3 side bet outcomes (poker hands).
|
||||||
|
enum TwentyOnePlusThreeResult: CaseIterable {
|
||||||
|
case suitedTrips // Three of a kind, same suit (e.g., 7♠ 7♠ 7♠)
|
||||||
|
case straightFlush // Straight + Flush (e.g., 5♠ 6♠ 7♠)
|
||||||
|
case threeOfAKind // Three of a kind, different suits (e.g., 7♠ 7♥ 7♦)
|
||||||
|
case straight // Three consecutive ranks (e.g., 5♠ 6♥ 7♦)
|
||||||
|
case flush // Three cards of same suit (e.g., 2♠ 7♠ K♠)
|
||||||
|
case nothing // No qualifying hand
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .suitedTrips: return String(localized: "Suited Trips")
|
||||||
|
case .straightFlush: return String(localized: "Straight Flush")
|
||||||
|
case .threeOfAKind: return String(localized: "Three of a Kind")
|
||||||
|
case .straight: return String(localized: "Straight")
|
||||||
|
case .flush: return String(localized: "Flush")
|
||||||
|
case .nothing: return String(localized: "No Hand")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payout multiplier (e.g., 100 means 100:1)
|
||||||
|
var payout: Int {
|
||||||
|
switch self {
|
||||||
|
case .suitedTrips: return 100
|
||||||
|
case .straightFlush: return 40
|
||||||
|
case .threeOfAKind: return 30
|
||||||
|
case .straight: return 10
|
||||||
|
case .flush: return 5
|
||||||
|
case .nothing: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isWin: Bool {
|
||||||
|
self != .nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Side Bet Evaluation
|
||||||
|
|
||||||
|
/// Evaluates side bet outcomes.
|
||||||
|
enum SideBetEvaluator {
|
||||||
|
|
||||||
|
/// Evaluates Perfect Pairs bet from player's first two cards.
|
||||||
|
static func evaluatePerfectPairs(card1: Card, card2: Card) -> PerfectPairsResult {
|
||||||
|
// Must be same rank for any pair
|
||||||
|
guard card1.rank == card2.rank else {
|
||||||
|
return .noPair
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perfect Pair: same suit
|
||||||
|
if card1.suit == card2.suit {
|
||||||
|
return .perfectPair
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colored Pair: same color (both red or both black), different suit
|
||||||
|
if card1.suit.isRed == card2.suit.isRed {
|
||||||
|
return .coloredPair
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixed Pair: different color
|
||||||
|
return .mixedPair
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluates 21+3 bet from player's first two cards + dealer's upcard.
|
||||||
|
static func evaluateTwentyOnePlusThree(playerCard1: Card, playerCard2: Card, dealerUpcard: Card) -> TwentyOnePlusThreeResult {
|
||||||
|
let cards = [playerCard1, playerCard2, dealerUpcard]
|
||||||
|
let ranks = cards.map { $0.rank }
|
||||||
|
let suits = cards.map { $0.suit }
|
||||||
|
|
||||||
|
let isFlush = suits.allSatisfy { $0 == suits[0] }
|
||||||
|
let isStraight = checkStraight(ranks: ranks)
|
||||||
|
let isThreeOfAKind = ranks.allSatisfy { $0 == ranks[0] }
|
||||||
|
|
||||||
|
// Check from highest payout to lowest
|
||||||
|
if isThreeOfAKind && isFlush {
|
||||||
|
return .suitedTrips
|
||||||
|
}
|
||||||
|
|
||||||
|
if isStraight && isFlush {
|
||||||
|
return .straightFlush
|
||||||
|
}
|
||||||
|
|
||||||
|
if isThreeOfAKind {
|
||||||
|
return .threeOfAKind
|
||||||
|
}
|
||||||
|
|
||||||
|
if isStraight {
|
||||||
|
return .straight
|
||||||
|
}
|
||||||
|
|
||||||
|
if isFlush {
|
||||||
|
return .flush
|
||||||
|
}
|
||||||
|
|
||||||
|
return .nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if three ranks form a straight (consecutive values).
|
||||||
|
/// Handles A-2-3 and Q-K-A straights.
|
||||||
|
private static func checkStraight(ranks: [Rank]) -> Bool {
|
||||||
|
// Convert to numeric values (Ace can be 1 or 14)
|
||||||
|
let values = ranks.map { rankValue($0) }.sorted()
|
||||||
|
|
||||||
|
// Check normal straight (consecutive)
|
||||||
|
if values[2] - values[1] == 1 && values[1] - values[0] == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check A-2-3 (values would be [1, 2, 3] after converting Ace to 1)
|
||||||
|
// But our rankValue gives Ace = 14, so check [2, 3, 14]
|
||||||
|
let sortedRanks = ranks.sorted { rankValue($0) < rankValue($1) }
|
||||||
|
let hasAce = sortedRanks.contains(.ace)
|
||||||
|
let hasTwo = sortedRanks.contains(.two)
|
||||||
|
let hasThree = sortedRanks.contains(.three)
|
||||||
|
|
||||||
|
if hasAce && hasTwo && hasThree {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts rank to numeric value for straight checking.
|
||||||
|
private static func rankValue(_ rank: Rank) -> Int {
|
||||||
|
switch rank {
|
||||||
|
case .ace: return 14 // High ace for Q-K-A
|
||||||
|
case .two: return 2
|
||||||
|
case .three: return 3
|
||||||
|
case .four: return 4
|
||||||
|
case .five: return 5
|
||||||
|
case .six: return 6
|
||||||
|
case .seven: return 7
|
||||||
|
case .eight: return 8
|
||||||
|
case .nine: return 9
|
||||||
|
case .ten: return 10
|
||||||
|
case .jack: return 11
|
||||||
|
case .queen: return 12
|
||||||
|
case .king: return 13
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Suit Extension for Color
|
||||||
|
|
||||||
|
extension Suit {
|
||||||
|
/// Whether this suit is red (hearts, diamonds).
|
||||||
|
var isRed: Bool {
|
||||||
|
self == .hearts || self == .diamonds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -66,6 +66,7 @@ struct BlackjackSettingsData: PersistableGameData {
|
|||||||
noHoleCard: false,
|
noHoleCard: false,
|
||||||
blackjackPayout: 1.5,
|
blackjackPayout: 1.5,
|
||||||
insuranceAllowed: true,
|
insuranceAllowed: true,
|
||||||
|
sideBetsEnabled: false,
|
||||||
showAnimations: true,
|
showAnimations: true,
|
||||||
dealingSpeed: 1.0,
|
dealingSpeed: 1.0,
|
||||||
showCardsRemaining: true,
|
showCardsRemaining: true,
|
||||||
@ -89,6 +90,7 @@ struct BlackjackSettingsData: PersistableGameData {
|
|||||||
var noHoleCard: Bool
|
var noHoleCard: Bool
|
||||||
var blackjackPayout: Double
|
var blackjackPayout: Double
|
||||||
var insuranceAllowed: Bool
|
var insuranceAllowed: Bool
|
||||||
|
var sideBetsEnabled: Bool
|
||||||
var showAnimations: Bool
|
var showAnimations: Bool
|
||||||
var dealingSpeed: Double
|
var dealingSpeed: Double
|
||||||
var showCardsRemaining: Bool
|
var showCardsRemaining: Bool
|
||||||
|
|||||||
@ -17,7 +17,7 @@ enum Design {
|
|||||||
// MARK: - Debug
|
// MARK: - Debug
|
||||||
|
|
||||||
/// Set to true to show layout debug borders on views
|
/// Set to true to show layout debug borders on views
|
||||||
static let showDebugBorders = true
|
static let showDebugBorders = false
|
||||||
|
|
||||||
// MARK: - Shared Constants (from CasinoKit)
|
// MARK: - Shared Constants (from CasinoKit)
|
||||||
|
|
||||||
@ -50,8 +50,8 @@ enum Design {
|
|||||||
// Hints
|
// Hints
|
||||||
static let hintFontSize: CGFloat = 15
|
static let hintFontSize: CGFloat = 15
|
||||||
static let hintIconSize: CGFloat = 24
|
static let hintIconSize: CGFloat = 24
|
||||||
static let hintPaddingH: CGFloat = 18
|
static let hintPaddingH: CGFloat = 10
|
||||||
static let hintPaddingV: CGFloat = 12
|
static let hintPaddingV: CGFloat = 10
|
||||||
|
|
||||||
// Hand icons
|
// Hand icons
|
||||||
static let handIconSize: CGFloat = 18
|
static let handIconSize: CGFloat = 18
|
||||||
@ -126,4 +126,13 @@ extension Color {
|
|||||||
enum TopBar {
|
enum TopBar {
|
||||||
static let balance = Color(red: 0.95, green: 0.85, blue: 0.4)
|
static let balance = Color(red: 0.95, green: 0.85, blue: 0.4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Side Bet Colors
|
||||||
|
|
||||||
|
enum SideBet {
|
||||||
|
/// Perfect Pairs - purple theme
|
||||||
|
static let perfectPairs = Color(red: 0.4, green: 0.2, blue: 0.5)
|
||||||
|
/// 21+3 - teal/cyan theme
|
||||||
|
static let twentyOnePlusThree = Color(red: 0.1, green: 0.4, blue: 0.5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,108 +0,0 @@
|
|||||||
//
|
|
||||||
// ActionButton.swift
|
|
||||||
// Blackjack
|
|
||||||
//
|
|
||||||
// Reusable styled button for game actions.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import CasinoKit
|
|
||||||
|
|
||||||
struct ActionButton: View {
|
|
||||||
let title: String
|
|
||||||
let icon: String?
|
|
||||||
let style: ButtonStyle
|
|
||||||
let action: () -> Void
|
|
||||||
|
|
||||||
enum ButtonStyle {
|
|
||||||
case primary // Gold gradient (Deal, New Round)
|
|
||||||
case destructive // Red (Clear)
|
|
||||||
case secondary // Subtle white
|
|
||||||
case custom(Color) // Game-specific colors (Hit, Stand, etc.)
|
|
||||||
|
|
||||||
var foregroundColor: Color {
|
|
||||||
switch self {
|
|
||||||
case .primary: return .black
|
|
||||||
case .destructive, .secondary, .custom: return .white
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init(_ title: String, icon: String? = nil, style: ButtonStyle = .primary, action: @escaping () -> Void) {
|
|
||||||
self.title = title
|
|
||||||
self.icon = icon
|
|
||||||
self.style = style
|
|
||||||
self.action = action
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: action) {
|
|
||||||
HStack(spacing: Design.Spacing.small) {
|
|
||||||
if let icon = icon {
|
|
||||||
Image(systemName: icon)
|
|
||||||
}
|
|
||||||
Text(title)
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(CasinoDesign.MinScaleFactor.relaxed)
|
|
||||||
}
|
|
||||||
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
|
|
||||||
.foregroundStyle(style.foregroundColor)
|
|
||||||
.padding(.horizontal, Design.Spacing.large)
|
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
|
||||||
.background(backgroundView)
|
|
||||||
}
|
|
||||||
.accessibilityLabel(title)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var backgroundView: some View {
|
|
||||||
switch style {
|
|
||||||
case .primary:
|
|
||||||
Capsule()
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
case .destructive:
|
|
||||||
Capsule()
|
|
||||||
.fill(Color.red.opacity(Design.Opacity.heavy))
|
|
||||||
case .secondary:
|
|
||||||
Capsule()
|
|
||||||
.fill(Color.white.opacity(Design.Opacity.hint))
|
|
||||||
case .custom(let color):
|
|
||||||
Capsule()
|
|
||||||
.fill(color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Previews
|
|
||||||
|
|
||||||
#Preview("Primary") {
|
|
||||||
ZStack {
|
|
||||||
Color.Table.felt.ignoresSafeArea()
|
|
||||||
ActionButton("Deal", icon: "play.fill", style: .primary) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Destructive") {
|
|
||||||
ZStack {
|
|
||||||
Color.Table.felt.ignoresSafeArea()
|
|
||||||
ActionButton("Clear", icon: "xmark.circle", style: .destructive) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Custom Colors") {
|
|
||||||
ZStack {
|
|
||||||
Color.Table.felt.ignoresSafeArea()
|
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
|
||||||
ActionButton("Hit", style: .custom(Color.Button.hit)) {}
|
|
||||||
ActionButton("Stand", style: .custom(Color.Button.stand)) {}
|
|
||||||
ActionButton("Double", style: .custom(Color.Button.doubleDown)) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -19,6 +19,9 @@ struct GameTableView: View {
|
|||||||
@State private var showRules = false
|
@State private var showRules = false
|
||||||
@State private var showStats = false
|
@State private var showStats = false
|
||||||
|
|
||||||
|
/// Full screen size (measured from TableBackgroundView - stable, doesn't change with content)
|
||||||
|
@State private var fullScreenSize: CGSize = CGSize(width: 375, height: 667)
|
||||||
|
|
||||||
// MARK: - Environment
|
// MARK: - Environment
|
||||||
|
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@ -73,8 +76,13 @@ struct GameTableView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func mainGameView(state: GameState) -> some View {
|
private func mainGameView(state: GameState) -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Background
|
// Background - measures full screen size (stable)
|
||||||
TableBackgroundView()
|
TableBackgroundView()
|
||||||
|
.onGeometryChange(for: CGSize.self) { proxy in
|
||||||
|
proxy.size
|
||||||
|
} action: { size in
|
||||||
|
fullScreenSize = size
|
||||||
|
}
|
||||||
|
|
||||||
mainContent(state: state)
|
mainContent(state: state)
|
||||||
}
|
}
|
||||||
@ -117,7 +125,8 @@ struct GameTableView: View {
|
|||||||
// Table layout - fills available space
|
// Table layout - fills available space
|
||||||
BlackjackTableView(
|
BlackjackTableView(
|
||||||
state: state,
|
state: state,
|
||||||
onPlaceBet: { placeBet(state: state) }
|
selectedChip: selectedChip,
|
||||||
|
fullScreenSize: fullScreenSize
|
||||||
)
|
)
|
||||||
.frame(maxWidth: maxContentWidth)
|
.frame(maxWidth: maxContentWidth)
|
||||||
|
|
||||||
@ -129,7 +138,7 @@ struct GameTableView: View {
|
|||||||
ChipSelectorView(
|
ChipSelectorView(
|
||||||
selectedChip: $selectedChip,
|
selectedChip: $selectedChip,
|
||||||
balance: state.balance,
|
balance: state.balance,
|
||||||
currentBet: state.currentBet,
|
currentBet: state.minBetForChipSelector, // Use min bet so chips stay enabled if any bet type can accept more
|
||||||
maxBet: state.settings.maxBet
|
maxBet: state.settings.maxBet
|
||||||
)
|
)
|
||||||
.frame(maxWidth: maxContentWidth)
|
.frame(maxWidth: maxContentWidth)
|
||||||
@ -182,11 +191,6 @@ struct GameTableView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Betting
|
|
||||||
|
|
||||||
private func placeBet(state: GameState) {
|
|
||||||
state.placeBet(amount: selectedChip.rawValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|||||||
@ -94,6 +94,25 @@ struct ResultBannerView: View {
|
|||||||
amount: showInsAmount ? result.insuranceWinnings : nil
|
amount: showInsAmount ? result.insuranceWinnings : nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Side bet results
|
||||||
|
if let ppResult = result.perfectPairsResult {
|
||||||
|
SideBetResultRow(
|
||||||
|
label: String(localized: "Perfect Pairs"),
|
||||||
|
resultText: ppResult.displayName,
|
||||||
|
isWin: ppResult.isWin,
|
||||||
|
amount: result.perfectPairsWinnings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let topResult = result.twentyOnePlusThreeResult {
|
||||||
|
SideBetResultRow(
|
||||||
|
label: String(localized: "21+3"),
|
||||||
|
resultText: topResult.displayName,
|
||||||
|
isWin: topResult.isWin,
|
||||||
|
amount: result.twentyOnePlusThreeWinnings
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.medium)
|
.padding(Design.Spacing.medium)
|
||||||
.background(
|
.background(
|
||||||
@ -245,6 +264,57 @@ struct ResultRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Side Bet Result Row
|
||||||
|
|
||||||
|
struct SideBetResultRow: View {
|
||||||
|
let label: String
|
||||||
|
let resultText: String
|
||||||
|
let isWin: Bool
|
||||||
|
let amount: Int
|
||||||
|
|
||||||
|
private var amountText: String {
|
||||||
|
if amount > 0 {
|
||||||
|
return "+$\(amount)"
|
||||||
|
} else if amount < 0 {
|
||||||
|
return "-$\(abs(amount))"
|
||||||
|
} else {
|
||||||
|
return "$0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var amountColor: Color {
|
||||||
|
if amount > 0 { return .green }
|
||||||
|
if amount < 0 { return .red }
|
||||||
|
return .blue
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resultColor: Color {
|
||||||
|
isWin ? .green : .red
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: Design.BaseFontSize.body))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(amountText)
|
||||||
|
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundStyle(amountColor)
|
||||||
|
.frame(width: 70, alignment: .trailing)
|
||||||
|
|
||||||
|
Text(resultText)
|
||||||
|
.font(.system(size: Design.BaseFontSize.body, weight: .bold))
|
||||||
|
.foregroundStyle(resultColor)
|
||||||
|
.frame(width: 100, alignment: .trailing)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview("Single Hand") {
|
#Preview("Single Hand") {
|
||||||
ResultBannerView(
|
ResultBannerView(
|
||||||
result: RoundResult(
|
result: RoundResult(
|
||||||
@ -296,3 +366,24 @@ struct ResultRow: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview("With Side Bets") {
|
||||||
|
ResultBannerView(
|
||||||
|
result: RoundResult(
|
||||||
|
handResults: [.win],
|
||||||
|
handWinnings: [100],
|
||||||
|
insuranceResult: nil,
|
||||||
|
insuranceWinnings: 0,
|
||||||
|
perfectPairsResult: .coloredPair,
|
||||||
|
perfectPairsWinnings: 300,
|
||||||
|
twentyOnePlusThreeResult: .nothing,
|
||||||
|
twentyOnePlusThreeWinnings: -25,
|
||||||
|
totalWinnings: 375,
|
||||||
|
wasBlackjack: false
|
||||||
|
),
|
||||||
|
currentBalance: 1375,
|
||||||
|
minBet: 10,
|
||||||
|
onNewRound: {},
|
||||||
|
onPlayAgain: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -82,6 +82,43 @@ struct RulesHelpView: View {
|
|||||||
String(localized: "Surrender: Half bet returned")
|
String(localized: "Surrender: Half bet returned")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
RulePage(
|
||||||
|
title: String(localized: "Side Bets"),
|
||||||
|
icon: "plus.circle.fill",
|
||||||
|
content: [
|
||||||
|
String(localized: "Optional bets placed before the deal."),
|
||||||
|
String(localized: "Perfect Pairs: Bet on your first two cards being a pair."),
|
||||||
|
String(localized: "21+3: Poker hand from your cards + dealer's upcard."),
|
||||||
|
String(localized: "Side bets have higher house edge than main game."),
|
||||||
|
String(localized: "Enable in Settings to play side bets.")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
RulePage(
|
||||||
|
title: String(localized: "Perfect Pairs"),
|
||||||
|
icon: "suit.heart.fill",
|
||||||
|
content: [
|
||||||
|
String(localized: "Bet on your first two cards forming a pair."),
|
||||||
|
String(localized: "Mixed Pair (diff. color): 6:1"),
|
||||||
|
String(localized: "Colored Pair (same color): 12:1"),
|
||||||
|
String(localized: "Perfect Pair (same suit): 25:1"),
|
||||||
|
String(localized: "Example: K♠ K♠ = Perfect Pair"),
|
||||||
|
String(localized: "Example: K♠ K♣ = Colored Pair (both black)"),
|
||||||
|
String(localized: "Example: K♠ K♥ = Mixed Pair (red/black)")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
RulePage(
|
||||||
|
title: String(localized: "21+3"),
|
||||||
|
icon: "dice.fill",
|
||||||
|
content: [
|
||||||
|
String(localized: "Forms a poker hand: your 2 cards + dealer upcard."),
|
||||||
|
String(localized: "Flush (same suit): 5:1"),
|
||||||
|
String(localized: "Straight (consecutive): 10:1"),
|
||||||
|
String(localized: "Three of a Kind: 30:1"),
|
||||||
|
String(localized: "Straight Flush: 40:1"),
|
||||||
|
String(localized: "Suited Trips (same rank & suit): 100:1"),
|
||||||
|
String(localized: "Aces can be high or low in straights (A-2-3 or Q-K-A).")
|
||||||
|
]
|
||||||
|
),
|
||||||
RulePage(
|
RulePage(
|
||||||
title: String(localized: "Vegas Strip"),
|
title: String(localized: "Vegas Strip"),
|
||||||
icon: "sparkles",
|
icon: "sparkles",
|
||||||
|
|||||||
@ -81,6 +81,16 @@ struct SettingsView: View {
|
|||||||
TableLimitsPicker(selection: $settings.tableLimits)
|
TableLimitsPicker(selection: $settings.tableLimits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3.5. Side Bets
|
||||||
|
SheetSection(title: String(localized: "SIDE BETS"), icon: "dollarsign.arrow.trianglehead.counterclockwise.rotate.90") {
|
||||||
|
SettingsToggle(
|
||||||
|
title: String(localized: "Enable Side Bets"),
|
||||||
|
subtitle: String(localized: "Perfect Pairs (25:1) and 21+3 (100:1)"),
|
||||||
|
isOn: $settings.sideBetsEnabled,
|
||||||
|
accentColor: accent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Deck Settings
|
// 4. Deck Settings
|
||||||
SheetSection(title: String(localized: "DECK SETTINGS"), icon: "rectangle.portrait.on.rectangle.portrait") {
|
SheetSection(title: String(localized: "DECK SETTINGS"), icon: "rectangle.portrait.on.rectangle.portrait") {
|
||||||
DeckCountPicker(selection: $settings.deckCount)
|
DeckCountPicker(selection: $settings.deckCount)
|
||||||
|
|||||||
@ -8,23 +8,93 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CasinoKit
|
import CasinoKit
|
||||||
|
|
||||||
|
/// Main betting zone view - adapts layout based on whether side bets are enabled.
|
||||||
|
/// Follows Baccarat's betting pattern exactly.
|
||||||
struct BettingZoneView: View {
|
struct BettingZoneView: View {
|
||||||
let betAmount: Int
|
@Bindable var state: GameState
|
||||||
let minBet: Int
|
let selectedChip: ChipDenomination
|
||||||
let maxBet: Int
|
|
||||||
let onTap: () -> Void
|
|
||||||
|
|
||||||
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
||||||
@ScaledMetric(relativeTo: .caption) private var detailFontSize: CGFloat = Design.Size.handNumberFontSize
|
@ScaledMetric(relativeTo: .caption) private var detailFontSize: CGFloat = Design.Size.handNumberFontSize
|
||||||
@ScaledMetric(relativeTo: .body) private var chipSize: CGFloat = Design.Size.bettingChipSize
|
@ScaledMetric(relativeTo: .body) private var chipSize: CGFloat = Design.Size.bettingChipSize
|
||||||
@ScaledMetric(relativeTo: .body) private var zoneHeight: CGFloat = CasinoDesign.Size.bettingZoneHeight
|
@ScaledMetric(relativeTo: .body) private var zoneHeight: CGFloat = CasinoDesign.Size.bettingZoneHeight
|
||||||
|
|
||||||
private var isAtMax: Bool {
|
// MARK: - Computed Properties (matches Baccarat's pattern)
|
||||||
betAmount >= maxBet
|
|
||||||
|
/// Whether a bet can be added to main bet (matches Baccarat's canAddBet)
|
||||||
|
private var canAddMainBet: Bool {
|
||||||
|
state.canPlaceBet &&
|
||||||
|
state.balance >= selectedChip.rawValue &&
|
||||||
|
state.canAddToMainBet(amount: selectedChip.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a bet can be added to Perfect Pairs
|
||||||
|
private var canAddPerfectPairs: Bool {
|
||||||
|
state.canPlaceBet &&
|
||||||
|
state.balance >= selectedChip.rawValue &&
|
||||||
|
state.canAddToSideBet(type: .perfectPairs, amount: selectedChip.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a bet can be added to 21+3
|
||||||
|
private var canAddTwentyOnePlusThree: Bool {
|
||||||
|
state.canPlaceBet &&
|
||||||
|
state.balance >= selectedChip.rawValue &&
|
||||||
|
state.canAddToSideBet(type: .twentyOnePlusThree, amount: selectedChip.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isMainAtMax: Bool {
|
||||||
|
state.currentBet >= state.settings.maxBet
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isPPAtMax: Bool {
|
||||||
|
state.perfectPairsBet >= state.settings.maxBet
|
||||||
|
}
|
||||||
|
|
||||||
|
private var is21PlusThreeAtMax: Bool {
|
||||||
|
state.twentyOnePlusThreeBet >= state.settings.maxBet
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onTap) {
|
if state.settings.sideBetsEnabled {
|
||||||
|
// Horizontal layout: PP | Main Bet | 21+3
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
// Perfect Pairs
|
||||||
|
SideBetZoneView(
|
||||||
|
betType: .perfectPairs,
|
||||||
|
betAmount: state.perfectPairsBet,
|
||||||
|
isEnabled: canAddPerfectPairs,
|
||||||
|
isAtMax: isPPAtMax,
|
||||||
|
onTap: { state.placeSideBet(type: .perfectPairs, amount: selectedChip.rawValue) }
|
||||||
|
)
|
||||||
|
.frame(width: Design.Size.sideBetZoneWidth)
|
||||||
|
|
||||||
|
// Main bet (center, takes remaining space)
|
||||||
|
mainBetZone
|
||||||
|
|
||||||
|
// 21+3
|
||||||
|
SideBetZoneView(
|
||||||
|
betType: .twentyOnePlusThree,
|
||||||
|
betAmount: state.twentyOnePlusThreeBet,
|
||||||
|
isEnabled: canAddTwentyOnePlusThree,
|
||||||
|
isAtMax: is21PlusThreeAtMax,
|
||||||
|
onTap: { state.placeSideBet(type: .twentyOnePlusThree, amount: selectedChip.rawValue) }
|
||||||
|
)
|
||||||
|
.frame(width: Design.Size.sideBetZoneWidth)
|
||||||
|
}
|
||||||
|
.frame(height: zoneHeight)
|
||||||
|
} else {
|
||||||
|
// Simple layout: just main bet
|
||||||
|
mainBetZone
|
||||||
|
.frame(height: zoneHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainBetZone: some View {
|
||||||
|
Button {
|
||||||
|
if canAddMainBet {
|
||||||
|
state.placeBet(amount: selectedChip.rawValue)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Background
|
// Background
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
@ -35,9 +105,9 @@ struct BettingZoneView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
if betAmount > 0 {
|
if state.currentBet > 0 {
|
||||||
// Show chip with amount (scaled)
|
// Show chip with amount (scaled)
|
||||||
ChipOnTableView(amount: betAmount, showMax: isAtMax, size: chipSize)
|
ChipOnTableView(amount: state.currentBet, showMax: isMainAtMax, size: chipSize)
|
||||||
} else {
|
} else {
|
||||||
// Empty state
|
// Empty state
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
@ -46,11 +116,11 @@ struct BettingZoneView: View {
|
|||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
Text(String(localized: "Min: $\(minBet)"))
|
Text(String(localized: "Min: $\(state.settings.minBet)"))
|
||||||
.font(.system(size: detailFontSize, weight: .medium))
|
.font(.system(size: detailFontSize, weight: .medium))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||||
|
|
||||||
Text(String(localized: "Max: $\(maxBet.formatted())"))
|
Text(String(localized: "Max: $\(state.settings.maxBet.formatted())"))
|
||||||
.font(.system(size: detailFontSize, weight: .medium))
|
.font(.system(size: detailFontSize, weight: .medium))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||||
}
|
}
|
||||||
@ -58,50 +128,64 @@ struct BettingZoneView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: zoneHeight)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(betAmount > 0 ? "$\(betAmount) bet" + (isAtMax ? ", maximum" : "") : "Place bet")
|
.accessibilityLabel(state.currentBet > 0 ? "$\(state.currentBet) bet" + (isMainAtMax ? ", maximum" : "") : "Place bet")
|
||||||
.accessibilityHint("Double tap to add chips")
|
.accessibilityHint("Double tap to add chips")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Design Constants Extension
|
||||||
|
|
||||||
|
extension Design.Size {
|
||||||
|
/// Width of side bet zones
|
||||||
|
static let sideBetZoneWidth: CGFloat = 70
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|
||||||
#Preview("Empty") {
|
#Preview("Empty - No Side Bets") {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.Table.felt.ignoresSafeArea()
|
Color.Table.felt.ignoresSafeArea()
|
||||||
BettingZoneView(
|
BettingZoneView(
|
||||||
betAmount: 0,
|
state: GameState(settings: GameSettings()),
|
||||||
minBet: 10,
|
selectedChip: .hundred
|
||||||
maxBet: 1000,
|
|
||||||
onTap: {}
|
|
||||||
)
|
)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("With Bet") {
|
#Preview("With Side Bets") {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.Table.felt.ignoresSafeArea()
|
Color.Table.felt.ignoresSafeArea()
|
||||||
BettingZoneView(
|
BettingZoneView(
|
||||||
betAmount: 250,
|
state: {
|
||||||
minBet: 10,
|
let settings = GameSettings()
|
||||||
maxBet: 1000,
|
settings.sideBetsEnabled = true
|
||||||
onTap: {}
|
let state = GameState(settings: settings)
|
||||||
|
state.placeBet(amount: 100)
|
||||||
|
return state
|
||||||
|
}(),
|
||||||
|
selectedChip: .twentyFive
|
||||||
)
|
)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("Max Bet") {
|
#Preview("All Bets Placed") {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.Table.felt.ignoresSafeArea()
|
Color.Table.felt.ignoresSafeArea()
|
||||||
BettingZoneView(
|
BettingZoneView(
|
||||||
betAmount: 1000,
|
state: {
|
||||||
minBet: 10,
|
let settings = GameSettings()
|
||||||
maxBet: 1000,
|
settings.sideBetsEnabled = true
|
||||||
onTap: {}
|
let state = GameState(settings: settings)
|
||||||
|
state.placeBet(amount: 250)
|
||||||
|
state.placeSideBet(type: .perfectPairs, amount: 25)
|
||||||
|
state.placeSideBet(type: .twentyOnePlusThree, amount: 50)
|
||||||
|
return state
|
||||||
|
}(),
|
||||||
|
selectedChip: .hundred
|
||||||
)
|
)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,17 +10,19 @@ import CasinoKit
|
|||||||
|
|
||||||
struct BlackjackTableView: View {
|
struct BlackjackTableView: View {
|
||||||
@Bindable var state: GameState
|
@Bindable var state: GameState
|
||||||
let onPlaceBet: () -> Void
|
let selectedChip: ChipDenomination
|
||||||
|
|
||||||
|
/// Full screen size passed from parent (stable - measured from TableBackgroundView)
|
||||||
|
let fullScreenSize: CGSize
|
||||||
|
|
||||||
// MARK: - Environment
|
// MARK: - Environment
|
||||||
|
|
||||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||||
|
|
||||||
// MARK: - State
|
// MARK: - Computed from stable screen size
|
||||||
|
|
||||||
/// Screen dimensions measured from container for responsive sizing
|
private var screenWidth: CGFloat { fullScreenSize.width }
|
||||||
@State private var screenHeight: CGFloat = 800
|
private var screenHeight: CGFloat { fullScreenSize.height }
|
||||||
@State private var screenWidth: CGFloat = 375
|
|
||||||
|
|
||||||
/// Whether to show Hi-Lo card count values on cards.
|
/// Whether to show Hi-Lo card count values on cards.
|
||||||
var showCardCount: Bool { state.settings.showCardCount }
|
var showCardCount: Bool { state.settings.showCardCount }
|
||||||
@ -43,13 +45,11 @@ struct BlackjackTableView: View {
|
|||||||
return verticalSizeClass == .compact
|
return verticalSizeClass == .compact
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Card width derived from screen height percentage and card aspect ratio
|
/// Card width based on full screen height (stable - doesn't change with content)
|
||||||
/// cardHeight = maxHeight × percentage, then cardWidth = cardHeight / aspectRatio
|
|
||||||
private var cardWidth: CGFloat {
|
private var cardWidth: CGFloat {
|
||||||
let maxHeight = max(screenWidth, screenHeight)
|
let maxDimension = screenHeight
|
||||||
let heightPercentage: CGFloat = 0.19 // Card takes 12% of screen height
|
let percentage: CGFloat = 0.13 // ~10% of screen
|
||||||
let cardHeight = maxHeight * heightPercentage
|
return maxDimension * percentage
|
||||||
return cardHeight
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Card overlap scales proportionally with card width
|
/// Card overlap scales proportionally with card width
|
||||||
@ -73,8 +73,8 @@ struct BlackjackTableView: View {
|
|||||||
/// - iPad Pro 12.9" (~1366pt): ~150pt (capped)
|
/// - iPad Pro 12.9" (~1366pt): ~150pt (capped)
|
||||||
private var dealerPlayerSpacing: CGFloat {
|
private var dealerPlayerSpacing: CGFloat {
|
||||||
let baseline: CGFloat = 550 // Below this, use minimum
|
let baseline: CGFloat = 550 // Below this, use minimum
|
||||||
let scale: CGFloat = 0.2 // 20% of height above baseline
|
let scale: CGFloat = 0.18 // 20% of height above baseline
|
||||||
let minSpacing: CGFloat = 20 // Floor for smallest screens
|
let minSpacing: CGFloat = 10 // Floor for smallest screens
|
||||||
let maxSpacing: CGFloat = 150 // Ceiling for largest screens
|
let maxSpacing: CGFloat = 150 // Ceiling for largest screens
|
||||||
|
|
||||||
let calculated = (screenHeight - baseline) * scale
|
let calculated = (screenHeight - baseline) * scale
|
||||||
@ -99,6 +99,7 @@ struct BlackjackTableView: View {
|
|||||||
|
|
||||||
// Player hands area - only show when there are cards dealt
|
// Player hands area - only show when there are cards dealt
|
||||||
if state.playerHands.first?.cards.isEmpty == false {
|
if state.playerHands.first?.cards.isEmpty == false {
|
||||||
|
ZStack {
|
||||||
PlayerHandsView(
|
PlayerHandsView(
|
||||||
hands: state.playerHands,
|
hands: state.playerHands,
|
||||||
activeHandIndex: state.activeHandIndex,
|
activeHandIndex: state.activeHandIndex,
|
||||||
@ -107,6 +108,38 @@ struct BlackjackTableView: View {
|
|||||||
cardWidth: cardWidth,
|
cardWidth: cardWidth,
|
||||||
cardSpacing: cardSpacing
|
cardSpacing: cardSpacing
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Side bet toasts (positioned on left/right sides to not cover cards)
|
||||||
|
if state.settings.sideBetsEnabled && state.showSideBetToasts {
|
||||||
|
HStack {
|
||||||
|
// PP on left
|
||||||
|
if state.perfectPairsBet > 0, let ppResult = state.perfectPairsResult {
|
||||||
|
SideBetToastView(
|
||||||
|
title: "PP",
|
||||||
|
result: ppResult.displayName,
|
||||||
|
isWin: ppResult.isWin,
|
||||||
|
amount: ppResult.isWin ? state.perfectPairsBet * ppResult.payout : -state.perfectPairsBet,
|
||||||
|
showOnLeft: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 21+3 on right
|
||||||
|
if state.twentyOnePlusThreeBet > 0, let topResult = state.twentyOnePlusThreeResult {
|
||||||
|
SideBetToastView(
|
||||||
|
title: "21+3",
|
||||||
|
result: topResult.displayName,
|
||||||
|
isWin: topResult.isWin,
|
||||||
|
amount: topResult.isWin ? state.twentyOnePlusThreeBet * topResult.payout : -state.twentyOnePlusThreeBet,
|
||||||
|
showOnLeft: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 5)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.debugBorder(showDebugBorders, color: .green, label: "Player")
|
.debugBorder(showDebugBorders, color: .green, label: "Player")
|
||||||
}
|
}
|
||||||
@ -117,10 +150,8 @@ struct BlackjackTableView: View {
|
|||||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer2")
|
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer2")
|
||||||
|
|
||||||
BettingZoneView(
|
BettingZoneView(
|
||||||
betAmount: state.currentBet,
|
state: state,
|
||||||
minBet: state.settings.minBet,
|
selectedChip: selectedChip
|
||||||
maxBet: state.settings.maxBet,
|
|
||||||
onTap: onPlaceBet
|
|
||||||
)
|
)
|
||||||
.transition(.scale.combined(with: .opacity))
|
.transition(.scale.combined(with: .opacity))
|
||||||
.debugBorder(showDebugBorders, color: .blue, label: "BetZone")
|
.debugBorder(showDebugBorders, color: .blue, label: "BetZone")
|
||||||
@ -145,12 +176,6 @@ struct BlackjackTableView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, Design.Spacing.large)
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
.onGeometryChange(for: CGSize.self) { proxy in
|
|
||||||
proxy.size
|
|
||||||
} action: { size in
|
|
||||||
screenWidth = size.width
|
|
||||||
screenHeight = size.height
|
|
||||||
}
|
|
||||||
.debugBorder(showDebugBorders, color: .white, label: "TableView")
|
.debugBorder(showDebugBorders, color: .white, label: "TableView")
|
||||||
.animation(.spring(duration: Design.Animation.springDuration), value: state.currentPhase)
|
.animation(.spring(duration: Design.Animation.springDuration), value: state.currentPhase)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ struct DealerHandView: View {
|
|||||||
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
// Label and value
|
// Label and value
|
||||||
HStack(spacing: Design.Spacing.small) {
|
HStack(spacing: Design.Spacing.small) {
|
||||||
@ -25,17 +26,17 @@ struct DealerHandView: View {
|
|||||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
// Show value: always show if hole card visible, or show single card value in European mode
|
// Show value: full value when hole card visible, otherwise just the face-up card's value
|
||||||
if !hand.cards.isEmpty {
|
if !hand.cards.isEmpty {
|
||||||
if showHoleCard {
|
if showHoleCard {
|
||||||
|
// All cards visible - show total hand value
|
||||||
ValueBadge(value: hand.value, color: Color.Hand.dealer)
|
ValueBadge(value: hand.value, color: Color.Hand.dealer)
|
||||||
} else if hand.cards.count == 1 {
|
} else {
|
||||||
// European mode: show single visible card value
|
// Hole card hidden - show only the first (face-up) card's value
|
||||||
ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer)
|
ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cards
|
// Cards
|
||||||
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
|
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
|
||||||
if hand.cards.isEmpty {
|
if hand.cards.isEmpty {
|
||||||
|
|||||||
@ -27,6 +27,8 @@ struct HintView: View {
|
|||||||
Text(String(localized: "Hint: \(hint)"))
|
Text(String(localized: "Hint: \(hint)"))
|
||||||
.font(.system(size: fontSize, weight: .medium))
|
.font(.system(size: fontSize, weight: .medium))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, paddingH)
|
.padding(.horizontal, paddingH)
|
||||||
.padding(.vertical, paddingV)
|
.padding(.vertical, paddingV)
|
||||||
@ -82,6 +84,8 @@ struct BettingHintView: View {
|
|||||||
Text(hint)
|
Text(hint)
|
||||||
.font(.system(size: fontSize, weight: .medium))
|
.font(.system(size: fontSize, weight: .medium))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, paddingH)
|
.padding(.horizontal, paddingH)
|
||||||
.padding(.vertical, paddingV)
|
.padding(.vertical, paddingV)
|
||||||
|
|||||||
@ -30,17 +30,17 @@ struct InsurancePopupView: View {
|
|||||||
|
|
||||||
// Title
|
// Title
|
||||||
Text(String(localized: "INSURANCE?"))
|
Text(String(localized: "INSURANCE?"))
|
||||||
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold))
|
.font(.system(size: Design.BaseFontSize.largeTitle, weight: .black, design: .rounded))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
// Subtitle
|
// Subtitle
|
||||||
Text(String(localized: "Dealer showing Ace"))
|
Text(String(localized: "Dealer showing Ace"))
|
||||||
.font(.system(size: Design.BaseFontSize.medium))
|
.font(.system(size: Design.BaseFontSize.xxLarge))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
|
|
||||||
// Cost info
|
// Cost info
|
||||||
Text(String(localized: "Cost: $\(betAmount) (half your bet)"))
|
Text(String(localized: "Cost: $\(betAmount) (half your bet)"))
|
||||||
.font(.system(size: Design.BaseFontSize.small))
|
.font(.system(size: Design.BaseFontSize.xxLarge))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
.padding(.bottom, Design.Spacing.small)
|
.padding(.bottom, Design.Spacing.small)
|
||||||
|
|
||||||
|
|||||||
@ -44,13 +44,11 @@ struct PlayerHandsView: View {
|
|||||||
.id(index)
|
.id(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Design.Spacing.large)
|
.padding(.horizontal, Design.Spacing.xxLarge) // More padding for scrolling
|
||||||
.containerRelativeFrame(.horizontal) { length, _ in
|
|
||||||
length // Ensures content fills container width for centering
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.scrollClipDisabled()
|
.scrollClipDisabled()
|
||||||
.scrollBounceBehavior(.basedOnSize)
|
.scrollBounceBehavior(.always) // Always allow bouncing for better scroll feel
|
||||||
|
.defaultScrollAnchor(.center) // Center the content by default
|
||||||
.onChange(of: activeHandIndex) { _, newIndex in
|
.onChange(of: activeHandIndex) { _, newIndex in
|
||||||
scrollToHand(proxy: proxy, index: newIndex)
|
scrollToHand(proxy: proxy, index: newIndex)
|
||||||
}
|
}
|
||||||
@ -66,6 +64,7 @@ struct PlayerHandsView: View {
|
|||||||
scrollToHand(proxy: proxy, index: activeHandIndex)
|
scrollToHand(proxy: proxy, index: activeHandIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollToHand(proxy: ScrollViewProxy, index: Int) {
|
private func scrollToHand(proxy: ScrollViewProxy, index: Int) {
|
||||||
|
|||||||
144
Blackjack/Blackjack/Views/Table/SideBetToastView.swift
Normal file
144
Blackjack/Blackjack/Views/Table/SideBetToastView.swift
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
//
|
||||||
|
// SideBetToastView.swift
|
||||||
|
// Blackjack
|
||||||
|
//
|
||||||
|
// Animated toast notifications for side bet results that appear after dealing.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CasinoKit
|
||||||
|
|
||||||
|
/// Toast notification for a single side bet result with built-in animation.
|
||||||
|
struct SideBetToastView: View {
|
||||||
|
let title: String
|
||||||
|
let result: String
|
||||||
|
let isWin: Bool
|
||||||
|
let amount: Int
|
||||||
|
let showOnLeft: Bool
|
||||||
|
|
||||||
|
@State private var isShowing = false
|
||||||
|
|
||||||
|
@ScaledMetric(relativeTo: .caption) private var titleFontSize: CGFloat = 10
|
||||||
|
@ScaledMetric(relativeTo: .caption2) private var resultFontSize: CGFloat = 11
|
||||||
|
@ScaledMetric(relativeTo: .caption2) private var amountFontSize: CGFloat = 13
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
isWin ? Color.green.opacity(Design.Opacity.heavy) : Color.red.opacity(Design.Opacity.heavy)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var borderColor: Color {
|
||||||
|
isWin ? Color.green : Color.red
|
||||||
|
}
|
||||||
|
|
||||||
|
private var amountText: String {
|
||||||
|
if amount > 0 {
|
||||||
|
return "+$\(amount)"
|
||||||
|
} else {
|
||||||
|
return "-$\(abs(amount))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.xxSmall) {
|
||||||
|
// Title (PP or 21+3)
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: titleFontSize, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
|
|
||||||
|
// Result
|
||||||
|
Text(result)
|
||||||
|
.font(.system(size: resultFontSize, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
||||||
|
|
||||||
|
// Amount
|
||||||
|
Text(amountText)
|
||||||
|
.font(.system(size: amountFontSize, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(isWin ? .yellow : .white)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.fill(backgroundColor)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.strokeBorder(borderColor, lineWidth: Design.LineWidth.medium)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.shadow(color: borderColor.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
|
||||||
|
.scaleEffect(isShowing ? 1.0 : 0.5)
|
||||||
|
.opacity(isShowing ? 1.0 : 0)
|
||||||
|
.offset(x: isShowing ? 0 : (showOnLeft ? -Design.Spacing.toastSlide : Design.Spacing.toastSlide))
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(title): \(result)")
|
||||||
|
.accessibilityValue(amountText)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: 0.4).delay(showOnLeft ? 0 : Design.Animation.staggerDelay1)) {
|
||||||
|
isShowing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews
|
||||||
|
|
||||||
|
#Preview("Win Toast Left") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
HStack {
|
||||||
|
SideBetToastView(
|
||||||
|
title: "PP",
|
||||||
|
result: "Perfect Pair",
|
||||||
|
isWin: true,
|
||||||
|
amount: 625,
|
||||||
|
showOnLeft: true
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Lose Toast Right") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
SideBetToastView(
|
||||||
|
title: "21+3",
|
||||||
|
result: "No Hand",
|
||||||
|
isWin: false,
|
||||||
|
amount: -25,
|
||||||
|
showOnLeft: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Both Toasts") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
HStack {
|
||||||
|
SideBetToastView(
|
||||||
|
title: "PP",
|
||||||
|
result: "Colored Pair",
|
||||||
|
isWin: true,
|
||||||
|
amount: 300,
|
||||||
|
showOnLeft: true
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
SideBetToastView(
|
||||||
|
title: "21+3",
|
||||||
|
result: "Flush",
|
||||||
|
isWin: true,
|
||||||
|
amount: 125,
|
||||||
|
showOnLeft: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
152
Blackjack/Blackjack/Views/Table/SideBetZoneView.swift
Normal file
152
Blackjack/Blackjack/Views/Table/SideBetZoneView.swift
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
//
|
||||||
|
// SideBetZoneView.swift
|
||||||
|
// Blackjack
|
||||||
|
//
|
||||||
|
// Side bet zone for Perfect Pairs and 21+3.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CasinoKit
|
||||||
|
|
||||||
|
/// A tappable zone for placing side bets.
|
||||||
|
struct SideBetZoneView: View {
|
||||||
|
let betType: SideBetType
|
||||||
|
let betAmount: Int
|
||||||
|
let isEnabled: Bool
|
||||||
|
let isAtMax: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
@ScaledMetric(relativeTo: .caption) private var labelFontSize: CGFloat = 11
|
||||||
|
@ScaledMetric(relativeTo: .caption2) private var payoutFontSize: CGFloat = 9
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
switch betType {
|
||||||
|
case .perfectPairs:
|
||||||
|
return Color.SideBet.perfectPairs
|
||||||
|
case .twentyOnePlusThree:
|
||||||
|
return Color.SideBet.twentyOnePlusThree
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var payoutText: String {
|
||||||
|
switch betType {
|
||||||
|
case .perfectPairs:
|
||||||
|
return "25:1" // Best payout shown
|
||||||
|
case .twentyOnePlusThree:
|
||||||
|
return "100:1" // Best payout shown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
if isEnabled { onTap() }
|
||||||
|
} label: {
|
||||||
|
ZStack {
|
||||||
|
// Background
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.fill(backgroundColor)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.strokeBorder(
|
||||||
|
Color.white.opacity(Design.Opacity.hint),
|
||||||
|
lineWidth: Design.LineWidth.thin
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Content
|
||||||
|
VStack(spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(betType.shortName)
|
||||||
|
.font(.system(size: labelFontSize, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
|
||||||
|
Text(payoutText)
|
||||||
|
.font(.system(size: payoutFontSize, weight: .medium, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chip indicator - top right
|
||||||
|
if betAmount > 0 {
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ChipBadgeView(amount: betAmount, isMax: isAtMax)
|
||||||
|
.padding(Design.Spacing.xSmall)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.opacity(isEnabled ? 1.0 : Design.Opacity.medium)
|
||||||
|
.accessibilityLabel("\(betType.displayName) bet, pays up to \(payoutText)")
|
||||||
|
.accessibilityHint(betAmount > 0 ? "Current bet $\(betAmount)" : "Double tap to place bet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small chip badge for side bet indicators.
|
||||||
|
struct ChipBadgeView: View {
|
||||||
|
let amount: Int
|
||||||
|
let isMax: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(isMax ? Color.gray : Color.yellow)
|
||||||
|
.frame(width: CasinoDesign.Size.chipBadge, height: CasinoDesign.Size.chipBadge)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(Color.white.opacity(Design.Opacity.almostFull), lineWidth: Design.LineWidth.thin)
|
||||||
|
.frame(width: CasinoDesign.Size.chipBadgeInner, height: CasinoDesign.Size.chipBadgeInner)
|
||||||
|
|
||||||
|
if isMax {
|
||||||
|
Text(String(localized: "MAX"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.xxSmall, weight: .black))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
} else {
|
||||||
|
Text(formatCompact(amount))
|
||||||
|
.font(.system(size: Design.BaseFontSize.small, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusSmall, y: Design.Shadow.offsetSmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatCompact(_ value: Int) -> String {
|
||||||
|
if value >= 1000 {
|
||||||
|
return "\(value / 1000)K"
|
||||||
|
}
|
||||||
|
return "\(value)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews
|
||||||
|
|
||||||
|
#Preview("Perfect Pairs") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
SideBetZoneView(
|
||||||
|
betType: .perfectPairs,
|
||||||
|
betAmount: 0,
|
||||||
|
isEnabled: true,
|
||||||
|
isAtMax: false,
|
||||||
|
onTap: {}
|
||||||
|
)
|
||||||
|
.frame(width: 80, height: 70)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("21+3 with Bet") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
SideBetZoneView(
|
||||||
|
betType: .twentyOnePlusThree,
|
||||||
|
betAmount: 25,
|
||||||
|
isEnabled: true,
|
||||||
|
isAtMax: false,
|
||||||
|
onTap: {}
|
||||||
|
)
|
||||||
|
.frame(width: 80, height: 70)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -16,15 +16,9 @@ let package = Package(
|
|||||||
targets: ["CasinoKit"]
|
targets: ["CasinoKit"]
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
dependencies: [
|
|
||||||
.package(url: "https://github.com/devicekit/DeviceKit.git", from: "5.0.0")
|
|
||||||
],
|
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "CasinoKit",
|
name: "CasinoKit",
|
||||||
dependencies: [
|
|
||||||
.product(name: "DeviceKit", package: "DeviceKit")
|
|
||||||
],
|
|
||||||
resources: [
|
resources: [
|
||||||
.process("Resources")
|
.process("Resources")
|
||||||
]
|
]
|
||||||
|
|||||||
@ -99,7 +99,7 @@ public final class SoundManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Master volume (0.0 to 1.0).
|
/// Master volume (0.0 to 1.0). This is the linear slider value.
|
||||||
public var volume: Float = 1.0 {
|
public var volume: Float = 1.0 {
|
||||||
didSet {
|
didSet {
|
||||||
UserDefaults.standard.set(volume, forKey: "casinokit.soundVolume")
|
UserDefaults.standard.set(volume, forKey: "casinokit.soundVolume")
|
||||||
@ -107,6 +107,15 @@ public final class SoundManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Perceived volume using an exponential curve.
|
||||||
|
/// Human hearing is logarithmic, so linear volume feels wrong.
|
||||||
|
/// This curve makes 50% on the slider sound like 50% to human ears.
|
||||||
|
private var perceivedVolume: Float {
|
||||||
|
// Using a power of 3 gives a natural-feeling curve
|
||||||
|
// 0.0 -> 0.0, 0.5 -> 0.125, 1.0 -> 1.0
|
||||||
|
pow(volume, 3)
|
||||||
|
}
|
||||||
|
|
||||||
/// The bundle to load sound files from. Defaults to CasinoKit's bundle.
|
/// The bundle to load sound files from. Defaults to CasinoKit's bundle.
|
||||||
/// Set this to `.main` if sounds are in your app bundle instead.
|
/// Set this to `.main` if sounds are in your app bundle instead.
|
||||||
public var soundBundle: Bundle = .module
|
public var soundBundle: Bundle = .module
|
||||||
@ -166,7 +175,7 @@ public final class SoundManager {
|
|||||||
do {
|
do {
|
||||||
let player = try AVAudioPlayer(contentsOf: url)
|
let player = try AVAudioPlayer(contentsOf: url)
|
||||||
player.prepareToPlay()
|
player.prepareToPlay()
|
||||||
player.volume = volume
|
player.volume = perceivedVolume
|
||||||
return player
|
return player
|
||||||
} catch {
|
} catch {
|
||||||
print("CasinoKit: Failed to create audio player for \(sound.rawValue): \(error)")
|
print("CasinoKit: Failed to create audio player for \(sound.rawValue): \(error)")
|
||||||
@ -193,8 +202,8 @@ public final class SoundManager {
|
|||||||
|
|
||||||
/// Plays a system sound by ID.
|
/// Plays a system sound by ID.
|
||||||
private func playSystemSound(_ soundID: SystemSoundID) {
|
private func playSystemSound(_ soundID: SystemSoundID) {
|
||||||
// Only play if volume is above threshold
|
// Only play if volume is above threshold (system sounds can't be volume-adjusted)
|
||||||
guard volume > 0.1 else { return }
|
guard perceivedVolume > 0.01 else { return }
|
||||||
AudioServicesPlaySystemSound(soundID)
|
AudioServicesPlaySystemSound(soundID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,7 +232,7 @@ public final class SoundManager {
|
|||||||
|
|
||||||
private func updatePlayerVolumes() {
|
private func updatePlayerVolumes() {
|
||||||
for player in audioPlayers.values {
|
for player in audioPlayers.values {
|
||||||
player.volume = volume
|
player.volume = perceivedVolume
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -22,6 +22,9 @@ public enum CasinoDesign {
|
|||||||
public static let xLarge: CGFloat = 20
|
public static let xLarge: CGFloat = 20
|
||||||
public static let xxLarge: CGFloat = 24
|
public static let xxLarge: CGFloat = 24
|
||||||
public static let xxxLarge: CGFloat = 32
|
public static let xxxLarge: CGFloat = 32
|
||||||
|
|
||||||
|
/// Slide distance for toast animations
|
||||||
|
public static let toastSlide: CGFloat = 50
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Corner Radius
|
// MARK: - Corner Radius
|
||||||
@ -149,7 +152,7 @@ public enum CasinoDesign {
|
|||||||
public static let actionButtonMinWidth: CGFloat = 80
|
public static let actionButtonMinWidth: CGFloat = 80
|
||||||
|
|
||||||
/// Betting zone height.
|
/// Betting zone height.
|
||||||
public static let bettingZoneHeight: CGFloat = 80
|
public static let bettingZoneHeight: CGFloat = 70
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Icon Sizes
|
// MARK: - Icon Sizes
|
||||||
|
|||||||
@ -1,196 +1,18 @@
|
|||||||
|
////
|
||||||
|
//// DeviceInfo.swift
|
||||||
|
//// CasinoKit
|
||||||
|
////
|
||||||
|
//// Device detection utilities using DeviceKit.
|
||||||
|
////
|
||||||
//
|
//
|
||||||
// DeviceInfo.swift
|
import Foundation
|
||||||
// CasinoKit
|
import UIKit
|
||||||
//
|
|
||||||
// Device detection utilities using DeviceKit.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
///// Device information utilities for responsive layouts.
|
||||||
@_exported import DeviceKit
|
|
||||||
|
|
||||||
/// Device information utilities for responsive layouts.
|
|
||||||
public enum DeviceInfo {
|
public enum DeviceInfo {
|
||||||
|
|
||||||
// MARK: - Current Device
|
|
||||||
|
|
||||||
/// The current device.
|
|
||||||
public static var current: Device {
|
|
||||||
Device.current
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Device Size Categories
|
|
||||||
public static var isSmallDevice: Bool {
|
|
||||||
isSmallPhone || isPadMini
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the current device is a small iPhone (SE series).
|
|
||||||
/// Includes iPhone SE (1st, 2nd, 3rd gen) and their simulators.
|
|
||||||
public static var isSmallPhone: Bool {
|
|
||||||
let smallPhones: [Device] = [
|
|
||||||
// iPhone SE series
|
|
||||||
.iPhoneSE,
|
|
||||||
.iPhoneSE2,
|
|
||||||
.iPhoneSE3,
|
|
||||||
// Simulators
|
|
||||||
.simulator(.iPhoneSE),
|
|
||||||
.simulator(.iPhoneSE2),
|
|
||||||
.simulator(.iPhoneSE3)
|
|
||||||
]
|
|
||||||
return current.isOneOf(smallPhones)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the current device is a standard iPhone (not SE, not Pro Max/Plus).
|
|
||||||
public static var isStandardPhone: Bool {
|
|
||||||
current.isPhone && !isSmallPhone && !isLargePhone
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the current device is a large iPhone (Pro Max, Plus models).
|
|
||||||
public static var isLargePhone: Bool {
|
|
||||||
let largePhones: [Device] = [
|
|
||||||
// Plus models
|
|
||||||
.iPhone6Plus, .iPhone6sPlus, .iPhone7Plus, .iPhone8Plus,
|
|
||||||
// Max models
|
|
||||||
.iPhoneXSMax, .iPhone11ProMax, .iPhone12ProMax,
|
|
||||||
.iPhone13ProMax, .iPhone14Plus, .iPhone14ProMax,
|
|
||||||
.iPhone15Plus, .iPhone15ProMax, .iPhone16Plus, .iPhone16ProMax,
|
|
||||||
// Simulators
|
|
||||||
.simulator(.iPhone6Plus), .simulator(.iPhone6sPlus),
|
|
||||||
.simulator(.iPhone7Plus), .simulator(.iPhone8Plus),
|
|
||||||
.simulator(.iPhoneXSMax), .simulator(.iPhone11ProMax),
|
|
||||||
.simulator(.iPhone12ProMax), .simulator(.iPhone13ProMax),
|
|
||||||
.simulator(.iPhone14Plus), .simulator(.iPhone14ProMax),
|
|
||||||
.simulator(.iPhone15Plus), .simulator(.iPhone15ProMax),
|
|
||||||
.simulator(.iPhone16Plus), .simulator(.iPhone16ProMax)
|
|
||||||
]
|
|
||||||
return current.isOneOf(largePhones)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the current device is an iPhone.
|
|
||||||
public static var isPhone: Bool {
|
|
||||||
current.isPhone
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the current device is an iPad.
|
/// Whether the current device is an iPad.
|
||||||
public static var isPad: Bool {
|
public static var isPad: Bool {
|
||||||
current.isPad
|
UIDevice.current.userInterfaceIdiom == .pad
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the current device is an iPad mini.
|
|
||||||
/// iPad minis have smaller screens than standard iPads (7.9" or 8.3").
|
|
||||||
/// Uses screen diagonal as fallback for reliable detection on real devices.
|
|
||||||
public static var isPadMini: Bool {
|
|
||||||
// First try exact device match
|
|
||||||
let minis: [Device] = [
|
|
||||||
.iPadMini, .iPadMini2, .iPadMini3, .iPadMini4,
|
|
||||||
.iPadMini5, .iPadMini6, .iPadMiniA17Pro,
|
|
||||||
.simulator(.iPadMini), .simulator(.iPadMini2), .simulator(.iPadMini3),
|
|
||||||
.simulator(.iPadMini4), .simulator(.iPadMini5), .simulator(.iPadMini6),
|
|
||||||
.simulator(.iPadMiniA17Pro)
|
|
||||||
]
|
|
||||||
if current.isOneOf(minis) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Check screen diagonal (iPad minis are 7.9" or 8.3")
|
|
||||||
// Other iPads are 10.2" and larger
|
|
||||||
if current.isPad {
|
|
||||||
let diagonal = current.diagonal
|
|
||||||
return diagonal > 0 && diagonal < 9.0
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the current device is a large iPad (Pro 12.9", 13").
|
|
||||||
public static var isLargePad: Bool {
|
|
||||||
let largePads: [Device] = [
|
|
||||||
.iPadPro12Inch, .iPadPro12Inch2, .iPadPro12Inch3,
|
|
||||||
.iPadPro12Inch4, .iPadPro12Inch5, .iPadPro12Inch6,
|
|
||||||
.iPadPro13M4,
|
|
||||||
.simulator(.iPadPro12Inch), .simulator(.iPadPro12Inch2),
|
|
||||||
.simulator(.iPadPro12Inch3), .simulator(.iPadPro12Inch4),
|
|
||||||
.simulator(.iPadPro12Inch5), .simulator(.iPadPro12Inch6),
|
|
||||||
.simulator(.iPadPro13M4)
|
|
||||||
]
|
|
||||||
return current.isOneOf(largePads)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether running in a simulator.
|
|
||||||
public static var isSimulator: Bool {
|
|
||||||
current.isSimulator
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Screen Size Helpers
|
|
||||||
|
|
||||||
/// The diagonal screen size in inches.
|
|
||||||
public static var screenDiagonal: Double {
|
|
||||||
current.diagonal
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The screen's pixels per inch.
|
|
||||||
public static var screenPPI: Int {
|
|
||||||
current.ppi ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Device Name
|
|
||||||
|
|
||||||
/// A human-readable description of the current device.
|
|
||||||
public static var deviceName: String {
|
|
||||||
current.description
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The real device when running in simulator, or the device itself.
|
|
||||||
public static var realDevice: Device {
|
|
||||||
current.realDevice
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Debug info about the current device (for troubleshooting).
|
|
||||||
public static var debugInfo: String {
|
|
||||||
let diag = String(format: "%.1f", screenDiagonal)
|
|
||||||
return "Device: \(current), diagonal: \(diag)\", isPadMini: \(isPadMini)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - SwiftUI Environment
|
|
||||||
|
|
||||||
/// Environment key for small phone detection.
|
|
||||||
private struct IsSmallPhoneKey: EnvironmentKey {
|
|
||||||
static let defaultValue: Bool = DeviceInfo.isSmallPhone
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
/// Whether the current device is a small phone (iPhone SE series).
|
|
||||||
public var isSmallPhone: Bool {
|
|
||||||
get { self[IsSmallPhoneKey.self] }
|
|
||||||
set { self[IsSmallPhoneKey.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - View Extension
|
|
||||||
|
|
||||||
extension View {
|
|
||||||
/// Applies different modifiers based on device size.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - small: Modifier to apply on small phones (iPhone SE)
|
|
||||||
/// - standard: Modifier to apply on standard phones
|
|
||||||
/// - large: Modifier to apply on large phones (Pro Max, Plus)
|
|
||||||
/// - pad: Modifier to apply on iPads
|
|
||||||
@ViewBuilder
|
|
||||||
public func deviceAdaptive<Small: View, Standard: View, Large: View, Pad: View>(
|
|
||||||
small: (Self) -> Small,
|
|
||||||
standard: (Self) -> Standard,
|
|
||||||
large: (Self) -> Large,
|
|
||||||
pad: (Self) -> Pad
|
|
||||||
) -> some View {
|
|
||||||
if DeviceInfo.isPad {
|
|
||||||
pad(self)
|
|
||||||
} else if DeviceInfo.isSmallPhone {
|
|
||||||
small(self)
|
|
||||||
} else if DeviceInfo.isLargePhone {
|
|
||||||
large(self)
|
|
||||||
} else {
|
|
||||||
standard(self)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,9 @@ public struct ActionButton: View {
|
|||||||
private let fontSize: CGFloat = 18
|
private let fontSize: CGFloat = 18
|
||||||
private let iconSize: CGFloat = 20
|
private let iconSize: CGFloat = 20
|
||||||
|
|
||||||
|
/// Minimum button width to prevent tiny buttons for short words like "Hit"
|
||||||
|
private let minButtonWidth: CGFloat = 35
|
||||||
|
|
||||||
/// Creates an action button.
|
/// Creates an action button.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - title: The button title.
|
/// - title: The button title.
|
||||||
@ -58,15 +61,19 @@ public struct ActionButton: View {
|
|||||||
}
|
}
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: fontSize, weight: .bold))
|
.font(.system(size: fontSize, weight: .bold))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(CasinoDesign.MinScaleFactor.relaxed)
|
||||||
}
|
}
|
||||||
.foregroundStyle(style.foregroundColor)
|
.foregroundStyle(style.foregroundColor)
|
||||||
.padding(.horizontal, CasinoDesign.Spacing.xxLarge)
|
.frame(minWidth: minButtonWidth)
|
||||||
|
.padding(.horizontal, CasinoDesign.Spacing.xLarge)
|
||||||
.padding(.vertical, CasinoDesign.Spacing.medium)
|
.padding(.vertical, CasinoDesign.Spacing.medium)
|
||||||
.background(style.background)
|
.background(style.background)
|
||||||
.shadow(color: style.shadowColor, radius: CasinoDesign.Shadow.radiusMedium)
|
.shadow(color: style.shadowColor, radius: CasinoDesign.Shadow.radiusMedium)
|
||||||
}
|
}
|
||||||
.disabled(!isEnabled)
|
.disabled(!isEnabled)
|
||||||
.opacity(isEnabled ? 1.0 : CasinoDesign.Opacity.medium)
|
.opacity(isEnabled ? 1.0 : CasinoDesign.Opacity.medium)
|
||||||
|
.accessibilityLabel(title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,12 +85,13 @@ public enum ActionButtonStyle {
|
|||||||
case destructive
|
case destructive
|
||||||
/// Secondary subtle button
|
/// Secondary subtle button
|
||||||
case secondary
|
case secondary
|
||||||
|
/// Custom color button for game-specific actions
|
||||||
|
case custom(Color)
|
||||||
|
|
||||||
var foregroundColor: Color {
|
var foregroundColor: Color {
|
||||||
switch self {
|
switch self {
|
||||||
case .primary: return .black
|
case .primary: return .black
|
||||||
case .destructive: return .white
|
case .destructive, .secondary, .custom: return .white
|
||||||
case .secondary: return .white
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,6 +113,9 @@ public enum ActionButtonStyle {
|
|||||||
case .secondary:
|
case .secondary:
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(Color.white.opacity(CasinoDesign.Opacity.hint))
|
.fill(Color.white.opacity(CasinoDesign.Opacity.hint))
|
||||||
|
case .custom(let color):
|
||||||
|
Capsule()
|
||||||
|
.fill(color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,7 +123,7 @@ public enum ActionButtonStyle {
|
|||||||
switch self {
|
switch self {
|
||||||
case .primary: return .yellow.opacity(CasinoDesign.Opacity.light)
|
case .primary: return .yellow.opacity(CasinoDesign.Opacity.light)
|
||||||
case .destructive: return .red.opacity(CasinoDesign.Opacity.light)
|
case .destructive: return .red.opacity(CasinoDesign.Opacity.light)
|
||||||
case .secondary: return .clear
|
case .secondary, .custom: return .clear
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -125,7 +136,21 @@ public enum ActionButtonStyle {
|
|||||||
ActionButton("Deal", icon: "play.fill", style: .primary) { }
|
ActionButton("Deal", icon: "play.fill", style: .primary) { }
|
||||||
ActionButton("Clear", icon: "xmark.circle", style: .destructive) { }
|
ActionButton("Clear", icon: "xmark.circle", style: .destructive) { }
|
||||||
ActionButton("Stand", style: .secondary) { }
|
ActionButton("Stand", style: .secondary) { }
|
||||||
ActionButton("Hit", style: .primary, isEnabled: false) { }
|
ActionButton("Hit", style: .custom(.green)) { }
|
||||||
|
ActionButton("Disabled", style: .primary, isEnabled: false) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("BlackJack Action Buttons") {
|
||||||
|
ZStack {
|
||||||
|
Color.CasinoTable.felt.ignoresSafeArea()
|
||||||
|
|
||||||
|
HStack(spacing: CasinoDesign.Spacing.medium) {
|
||||||
|
ActionButton("Hit", style: .custom(.green)) { }
|
||||||
|
ActionButton("Stand", style: .secondary) { }
|
||||||
|
ActionButton("Double", style: .custom(Color.purple)) {}
|
||||||
|
ActionButton("Split", style: .custom(Color.yellow)) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,194 +0,0 @@
|
|||||||
//
|
|
||||||
// HandDisplayView.swift
|
|
||||||
// CasinoKit
|
|
||||||
//
|
|
||||||
// A generic view for displaying a hand of cards with optional overlap.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// A view displaying a hand of cards with configurable layout.
|
|
||||||
public struct HandDisplayView: View {
|
|
||||||
/// The cards in the hand.
|
|
||||||
public let cards: [Card]
|
|
||||||
|
|
||||||
/// Which cards are face up (by index).
|
|
||||||
public let cardsFaceUp: [Bool]
|
|
||||||
|
|
||||||
/// The width of each card.
|
|
||||||
public let cardWidth: CGFloat
|
|
||||||
|
|
||||||
/// The overlap between cards (negative = overlap, positive = gap).
|
|
||||||
public let cardSpacing: CGFloat
|
|
||||||
|
|
||||||
/// Whether this hand is the winner.
|
|
||||||
public let isWinner: Bool
|
|
||||||
|
|
||||||
/// Optional label to show (e.g., "PLAYER", "DEALER").
|
|
||||||
public let label: String?
|
|
||||||
|
|
||||||
/// Optional value badge to show.
|
|
||||||
public let value: Int?
|
|
||||||
|
|
||||||
/// Badge color for the value.
|
|
||||||
public let valueColor: Color
|
|
||||||
|
|
||||||
/// Maximum number of card slots to reserve space for.
|
|
||||||
public let maxCards: Int
|
|
||||||
|
|
||||||
// Layout
|
|
||||||
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = 14
|
|
||||||
@ScaledMetric(relativeTo: .caption) private var winBadgeFontSize: CGFloat = 10
|
|
||||||
|
|
||||||
/// Creates a hand display view.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - cards: The cards to display.
|
|
||||||
/// - cardsFaceUp: Which cards are face up.
|
|
||||||
/// - cardWidth: Width of each card.
|
|
||||||
/// - cardSpacing: Spacing between cards (negative for overlap).
|
|
||||||
/// - isWinner: Whether to show winner styling.
|
|
||||||
/// - label: Optional label above cards.
|
|
||||||
/// - value: Optional value badge to show.
|
|
||||||
/// - valueColor: Color for value badge.
|
|
||||||
/// - maxCards: Max cards to reserve space for (default: 3).
|
|
||||||
public init(
|
|
||||||
cards: [Card],
|
|
||||||
cardsFaceUp: [Bool] = [],
|
|
||||||
cardWidth: CGFloat = 45,
|
|
||||||
cardSpacing: CGFloat = -12,
|
|
||||||
isWinner: Bool = false,
|
|
||||||
label: String? = nil,
|
|
||||||
value: Int? = nil,
|
|
||||||
valueColor: Color = .blue,
|
|
||||||
maxCards: Int = 3
|
|
||||||
) {
|
|
||||||
self.cards = cards
|
|
||||||
self.cardsFaceUp = cardsFaceUp
|
|
||||||
self.cardWidth = cardWidth
|
|
||||||
self.cardSpacing = cardSpacing
|
|
||||||
self.isWinner = isWinner
|
|
||||||
self.label = label
|
|
||||||
self.value = value
|
|
||||||
self.valueColor = valueColor
|
|
||||||
self.maxCards = maxCards
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Card height based on aspect ratio.
|
|
||||||
private var cardHeight: CGFloat {
|
|
||||||
cardWidth * CasinoDesign.Size.cardAspectRatio
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fixed container width based on max cards.
|
|
||||||
private var containerWidth: CGFloat {
|
|
||||||
if maxCards <= 1 {
|
|
||||||
return cardWidth + CasinoDesign.Spacing.xSmall * 2
|
|
||||||
}
|
|
||||||
let cardsWidth = cardWidth + (cardWidth + cardSpacing) * CGFloat(maxCards - 1)
|
|
||||||
return cardsWidth + CasinoDesign.Spacing.xSmall * 2
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fixed container height.
|
|
||||||
private var containerHeight: CGFloat {
|
|
||||||
cardHeight + CasinoDesign.Spacing.xSmall * 2
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
VStack(spacing: CasinoDesign.Spacing.small) {
|
|
||||||
// Label with optional value badge
|
|
||||||
if label != nil || value != nil {
|
|
||||||
HStack(spacing: CasinoDesign.Spacing.small) {
|
|
||||||
if let label = label {
|
|
||||||
Text(label)
|
|
||||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let value = value, !cards.isEmpty {
|
|
||||||
ValueBadge(value: value, color: valueColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(minHeight: CasinoDesign.Spacing.xxxLarge)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cards container
|
|
||||||
ZStack {
|
|
||||||
// Fixed-size container
|
|
||||||
Color.clear
|
|
||||||
.frame(width: containerWidth, height: containerHeight)
|
|
||||||
|
|
||||||
// Cards
|
|
||||||
HStack(spacing: cards.isEmpty ? CasinoDesign.Spacing.small : cardSpacing) {
|
|
||||||
if cards.isEmpty {
|
|
||||||
// Placeholders
|
|
||||||
ForEach(0..<min(2, maxCards), id: \.self) { _ in
|
|
||||||
CardPlaceholderView(width: cardWidth)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ForEach(cards.indices, id: \.self) { index in
|
|
||||||
let isFaceUp = index < cardsFaceUp.count ? cardsFaceUp[index] : true
|
|
||||||
CardView(
|
|
||||||
card: cards[index],
|
|
||||||
isFaceUp: isFaceUp,
|
|
||||||
cardWidth: cardWidth
|
|
||||||
)
|
|
||||||
.zIndex(Double(index))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.small)
|
|
||||||
.strokeBorder(
|
|
||||||
isWinner ? Color.yellow : Color.clear,
|
|
||||||
lineWidth: CasinoDesign.LineWidth.medium
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.overlay(alignment: .bottom) {
|
|
||||||
if isWinner {
|
|
||||||
Text(String(localized: "WIN", bundle: .module))
|
|
||||||
.font(.system(size: winBadgeFontSize, weight: .black))
|
|
||||||
.foregroundStyle(.black)
|
|
||||||
.padding(.horizontal, CasinoDesign.Spacing.small)
|
|
||||||
.padding(.vertical, CasinoDesign.Spacing.xxSmall)
|
|
||||||
.background(
|
|
||||||
Capsule()
|
|
||||||
.fill(Color.yellow)
|
|
||||||
)
|
|
||||||
.offset(y: CasinoDesign.Spacing.small)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ZStack {
|
|
||||||
Color.CasinoTable.felt.ignoresSafeArea()
|
|
||||||
|
|
||||||
HStack(spacing: 40) {
|
|
||||||
HandDisplayView(
|
|
||||||
cards: [
|
|
||||||
Card(suit: .hearts, rank: .ace),
|
|
||||||
Card(suit: .spades, rank: .king)
|
|
||||||
],
|
|
||||||
cardsFaceUp: [true, true],
|
|
||||||
isWinner: true,
|
|
||||||
label: "PLAYER",
|
|
||||||
value: 21,
|
|
||||||
valueColor: .blue
|
|
||||||
)
|
|
||||||
|
|
||||||
HandDisplayView(
|
|
||||||
cards: [
|
|
||||||
Card(suit: .diamonds, rank: .seven),
|
|
||||||
Card(suit: .clubs, rank: .ten)
|
|
||||||
],
|
|
||||||
cardsFaceUp: [true, false],
|
|
||||||
label: "DEALER",
|
|
||||||
value: 17,
|
|
||||||
valueColor: .red
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,168 +0,0 @@
|
|||||||
//
|
|
||||||
// BettingZone.swift
|
|
||||||
// CasinoKit
|
|
||||||
//
|
|
||||||
// A reusable betting zone for casino table layouts.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// A tappable betting zone with label and chip display.
|
|
||||||
public struct BettingZone: View {
|
|
||||||
/// The zone label (e.g., "PLAYER", "TIE", "INSURANCE").
|
|
||||||
public let label: String
|
|
||||||
|
|
||||||
/// Optional payout info (e.g., "1:1", "8:1").
|
|
||||||
public let payoutInfo: String?
|
|
||||||
|
|
||||||
/// Current bet amount (0 if no bet).
|
|
||||||
public let betAmount: Int
|
|
||||||
|
|
||||||
/// Whether the zone is enabled for betting.
|
|
||||||
public let isEnabled: Bool
|
|
||||||
|
|
||||||
/// Action when the zone is tapped.
|
|
||||||
public let onTap: () -> Void
|
|
||||||
|
|
||||||
/// Background color for the zone.
|
|
||||||
public let backgroundColor: Color
|
|
||||||
|
|
||||||
/// Text color.
|
|
||||||
public let textColor: Color
|
|
||||||
|
|
||||||
// Layout
|
|
||||||
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = 16
|
|
||||||
@ScaledMetric(relativeTo: .caption) private var payoutFontSize: CGFloat = 12
|
|
||||||
|
|
||||||
/// Creates a betting zone.
|
|
||||||
public init(
|
|
||||||
label: String,
|
|
||||||
payoutInfo: String? = nil,
|
|
||||||
betAmount: Int = 0,
|
|
||||||
isEnabled: Bool = true,
|
|
||||||
backgroundColor: Color = .blue.opacity(0.2),
|
|
||||||
textColor: Color = .white,
|
|
||||||
onTap: @escaping () -> Void
|
|
||||||
) {
|
|
||||||
self.label = label
|
|
||||||
self.payoutInfo = payoutInfo
|
|
||||||
self.betAmount = betAmount
|
|
||||||
self.isEnabled = isEnabled
|
|
||||||
self.backgroundColor = backgroundColor
|
|
||||||
self.textColor = textColor
|
|
||||||
self.onTap = onTap
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
Button(action: onTap) {
|
|
||||||
ZStack {
|
|
||||||
// Background
|
|
||||||
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
|
|
||||||
.fill(backgroundColor)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
|
|
||||||
.strokeBorder(
|
|
||||||
textColor.opacity(CasinoDesign.Opacity.light),
|
|
||||||
lineWidth: CasinoDesign.LineWidth.thin
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Content
|
|
||||||
VStack(spacing: CasinoDesign.Spacing.xxSmall) {
|
|
||||||
Text(label)
|
|
||||||
.font(.system(size: labelFontSize, weight: .bold))
|
|
||||||
.foregroundStyle(textColor)
|
|
||||||
|
|
||||||
if let payout = payoutInfo {
|
|
||||||
Text(payout)
|
|
||||||
.font(.system(size: payoutFontSize, weight: .medium))
|
|
||||||
.foregroundStyle(textColor.opacity(CasinoDesign.Opacity.medium))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chip badge for bet amount
|
|
||||||
if betAmount > 0 {
|
|
||||||
ChipBadge(amount: betAmount)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
|
||||||
.padding(CasinoDesign.Spacing.xSmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(!isEnabled)
|
|
||||||
.accessibilityLabel(label)
|
|
||||||
.accessibilityValue(betAmount > 0 ? "$\(betAmount) bet" : "No bet")
|
|
||||||
.accessibilityHint(isEnabled ? "Double tap to place bet" : "Betting disabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A small chip badge showing bet amount.
|
|
||||||
public struct ChipBadge: View {
|
|
||||||
public let amount: Int
|
|
||||||
|
|
||||||
private let badgeSize: CGFloat = 28
|
|
||||||
private let fontSize: CGFloat = 10
|
|
||||||
|
|
||||||
public init(amount: Int) {
|
|
||||||
self.amount = amount
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.yellow)
|
|
||||||
.frame(width: badgeSize, height: badgeSize)
|
|
||||||
|
|
||||||
Circle()
|
|
||||||
.strokeBorder(Color.orange, lineWidth: 2)
|
|
||||||
.frame(width: badgeSize - 4, height: badgeSize - 4)
|
|
||||||
|
|
||||||
Text(formattedAmount)
|
|
||||||
.font(.system(size: fontSize, weight: .bold))
|
|
||||||
.foregroundStyle(.black)
|
|
||||||
.minimumScaleFactor(0.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var formattedAmount: String {
|
|
||||||
if amount >= 1_000_000 {
|
|
||||||
return "\(amount / 1_000_000)M"
|
|
||||||
} else if amount >= 1_000 {
|
|
||||||
return "\(amount / 1_000)K"
|
|
||||||
}
|
|
||||||
return "\(amount)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ZStack {
|
|
||||||
Color.CasinoTable.felt.ignoresSafeArea()
|
|
||||||
|
|
||||||
HStack(spacing: 20) {
|
|
||||||
BettingZone(
|
|
||||||
label: "PLAYER",
|
|
||||||
payoutInfo: "1:1",
|
|
||||||
betAmount: 0,
|
|
||||||
backgroundColor: .blue.opacity(0.2)
|
|
||||||
) { }
|
|
||||||
.frame(width: 120, height: 80)
|
|
||||||
|
|
||||||
BettingZone(
|
|
||||||
label: "TIE",
|
|
||||||
payoutInfo: "8:1",
|
|
||||||
betAmount: 500,
|
|
||||||
backgroundColor: .green.opacity(0.2)
|
|
||||||
) { }
|
|
||||||
.frame(width: 80, height: 80)
|
|
||||||
|
|
||||||
BettingZone(
|
|
||||||
label: "BANKER",
|
|
||||||
payoutInfo: "0.95:1",
|
|
||||||
betAmount: 2500,
|
|
||||||
backgroundColor: .red.opacity(0.2)
|
|
||||||
) { }
|
|
||||||
.frame(width: 120, height: 80)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user