Compare commits

...

19 Commits

Author SHA1 Message Date
ed1dabfe18 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-31 14:02:52 -06:00
c9b2a9e692 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-31 13:44:26 -06:00
3be7fc5884 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-31 13:29:45 -06:00
43727534e6 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-31 13:17:41 -06:00
bda234a3bb Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-31 12:59:13 -06:00
04fc1542f5 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-31 10:27:43 -06:00
ca742eb73f Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-31 09:57:45 -06:00
e1655ce20c Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-31 09:52:56 -06:00
3aa1ed77ae Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-31 09:48:30 -06:00
1fe7bbb274 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-29 16:39:20 -06:00
abf4ba9b97 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-29 14:38:54 -06:00
178d28ca6c Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-29 14:07:23 -06:00
2a55a16227 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-29 13:51:26 -06:00
f1b834c47e Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-29 13:48:03 -06:00
10d3d02cb0 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-29 13:39:19 -06:00
9433ced1aa Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-29 13:11:17 -06:00
247435a405 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-29 13:03:03 -06:00
982d54ed1d Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-29 12:22:14 -06:00
a9b4f95bb4 Signed-off-by: Matt Bruce <mbrucedogs@gmail.com> 2025-12-29 11:56:47 -06:00
53 changed files with 7358 additions and 1554 deletions

132
Agents.md
View File

@ -13,10 +13,142 @@ You are a **Senior iOS Engineer**, specializing in SwiftUI, SwiftData, and relat
- Target iOS 26.0 or later. (Yes, it definitely exists.)
- Swift 6.2 or later, using modern Swift concurrency.
- SwiftUI backed up by `@Observable` classes for shared data.
- **Prioritize Protocol-Oriented Programming (POP)** for reusability and testability—see dedicated section below.
- Do not introduce third-party frameworks without asking first.
- Avoid UIKit unless requested.
## Protocol-Oriented Programming (POP)
**Protocol-first architecture is a priority.** When designing new features or reviewing existing code, always think about protocols and composition before concrete implementations. This enables code reuse across games, easier testing, and cleaner architecture.
### When architecting new code:
1. **Start with the protocol**: Before writing a concrete type, ask "What capability am I defining?" and express it as a protocol.
2. **Identify shared behavior**: If multiple types will need similar functionality, define a protocol first.
3. **Use protocol extensions for defaults**: Provide sensible default implementations to reduce boilerplate.
4. **Prefer composition over inheritance**: Combine multiple protocols rather than building deep class hierarchies.
### When reviewing existing code for reuse:
1. **Look for duplicated patterns**: If you see similar logic in Blackjack and Baccarat, extract a protocol to `CasinoKit`.
2. **Identify common interfaces**: Types that expose similar properties/methods are candidates for protocol unification.
3. **Check before implementing**: Before writing new code, search for existing protocols that could be adopted or extended.
4. **Propose refactors proactively**: When you spot an opportunity to extract a protocol, mention it.
### Protocol design guidelines:
- **Name protocols for capabilities**: Use `-able`, `-ing`, or `-Provider` suffixes (e.g., `Bettable`, `CardDealing`, `StatisticsProvider`).
- **Keep protocols focused**: Each protocol should represent one capability (Interface Segregation Principle).
- **Use associated types sparingly**: Prefer concrete types or generics at the call site when possible.
- **Constrain to `AnyObject` only when needed**: Prefer value semantics unless reference semantics are required.
### Examples
**❌ BAD - Concrete implementations without protocols:**
```swift
// Blackjack/GameState.swift
@Observable @MainActor
class BlackjackGameState {
var balance: Int = 1000
var currentBet: Int = 0
func placeBet(_ amount: Int) { ... }
func resetBet() { ... }
}
// Baccarat/GameState.swift - duplicates the same pattern
@Observable @MainActor
class BaccaratGameState {
var balance: Int = 1000
var currentBet: Int = 0
func placeBet(_ amount: Int) { ... }
func resetBet() { ... }
}
```
**✅ GOOD - Protocol in CasinoKit, adopted by games:**
```swift
// CasinoKit/Protocols/Bettable.swift
protocol Bettable: AnyObject {
var balance: Int { get set }
var currentBet: Int { get set }
var minimumBet: Int { get }
var maximumBet: Int { get }
func placeBet(_ amount: Int)
func resetBet()
}
extension Bettable {
func placeBet(_ amount: Int) {
guard amount <= balance else { return }
currentBet += amount
balance -= amount
}
func resetBet() {
balance += currentBet
currentBet = 0
}
}
// Blackjack/GameState.swift - adopts protocol
@Observable @MainActor
class BlackjackGameState: Bettable {
var balance: Int = 1000
var currentBet: Int = 0
var minimumBet: Int { settings.minBet }
var maximumBet: Int { settings.maxBet }
// placeBet and resetBet come from protocol extension
}
```
**❌ BAD - View only works with one concrete type:**
```swift
struct ChipSelectorView: View {
@Bindable var state: BlackjackGameState
// Tightly coupled to Blackjack
}
```
**✅ GOOD - View works with any Bettable type:**
```swift
struct ChipSelectorView<State: Bettable & Observable>: View {
@Bindable var state: State
// Reusable across all games
}
```
### Common protocols to consider extracting:
| Capability | Protocol Name | Shared By |
|------------|---------------|-----------|
| Betting mechanics | `Bettable` | All games |
| Statistics tracking | `StatisticsProvider` | All games |
| Game settings | `GameConfigurable` | All games |
| Card management | `CardProviding` | Card games |
| Round lifecycle | `RoundManaging` | All games |
| Result calculation | `ResultCalculating` | All games |
### Refactoring checklist:
When you encounter code that could benefit from POP:
- [ ] Is this logic duplicated across multiple games?
- [ ] Could this type conform to an existing protocol in CasinoKit?
- [ ] Would extracting a protocol make this code testable in isolation?
- [ ] Can views be made generic over a protocol instead of a concrete type?
- [ ] Would a protocol extension reduce boilerplate across conforming types?
### Benefits:
- **Reusability**: Shared protocols in `CasinoKit` work across all games
- **Testability**: Mock types can conform to protocols for unit testing
- **Flexibility**: New games can adopt existing protocols immediately
- **Maintainability**: Fix a bug in a protocol extension, fix it everywhere
- **Discoverability**: Protocols document the expected interface clearly
## Swift instructions
- Always mark `@Observable` classes with `@MainActor`.

View File

@ -45,9 +45,33 @@ struct BetResult: Identifiable {
}
/// Main observable game state class managing all game logic and UI state.
/// Conforms to CasinoGameState for shared game behaviors.
@Observable
@MainActor
final class GameState {
final class GameState: CasinoGameState {
// MARK: - CasinoGameState Conformance
typealias Stats = BaccaratStats
typealias GameSettingsType = GameSettings
/// The currently active session.
var currentSession: BaccaratSession?
/// History of completed sessions.
var sessionHistory: [BaccaratSession] = []
/// Starting balance for new sessions (from settings).
var startingBalance: Int { settings.startingBalance }
/// Current game style identifier (deck count for Baccarat).
var currentGameStyle: String { settings.deckCount.displayName }
/// Whether a session end has been requested (shows confirmation).
var showEndSessionConfirmation: Bool = false
/// Round histories for completed sessions (keyed by session ID string).
private var sessionRoundHistories: [String: [SavedRoundResult]] = [:]
// MARK: - Settings
let settings: GameSettings
@ -58,13 +82,13 @@ final class GameState {
private let sound = SoundManager.shared
// MARK: - Persistence
private var persistence: CloudSyncManager<BaccaratGameData>!
let persistence: CloudSyncManager<BaccaratGameData>
// MARK: - Game Engine
private(set) var engine: BaccaratEngine
// MARK: - Player State
var balance: Int = 10_000
var balance: Int = 1_000
var currentBets: [Bet] = []
// MARK: - Round State
@ -135,6 +159,11 @@ final class GameState {
Array(roundHistory.suffix(20))
}
/// Whether the game is over (can't afford to meet minimum bet).
var isGameOver: Bool {
currentPhase == .betting && balance < settings.minBet
}
// MARK: - Hint System
/// The current streak type (player wins, banker wins, or alternating/none).
@ -365,41 +394,40 @@ final class GameState {
self.engine = BaccaratEngine(deckCount: settings.deckCount.rawValue)
self.balance = settings.startingBalance
self.onboarding = OnboardingState(gameIdentifier: "baccarat")
self.onboarding.registerHintKeys("bettingZone", "dealButton", "firstResult")
self.persistence = CloudSyncManager<BaccaratGameData>()
// Sync sound settings with SoundManager
syncSoundSettings()
// Initialize persistence with cloud data callback
self.persistence = CloudSyncManager<BaccaratGameData>()
// Set up iCloud callback
persistence.onCloudDataReceived = { [weak self] cloudData in
self?.handleCloudDataReceived(cloudData)
}
// Load saved game data
loadSavedGame()
// Ensure we have an active session
ensureActiveSession()
}
/// Handles data received from iCloud (e.g., after fresh install or from another device).
private func handleCloudDataReceived(_ cloudData: BaccaratGameData) {
// Only update if cloud has more progress than current state
guard cloudData.roundsPlayed > roundHistory.count else {
guard cloudData.roundsPlayed > (currentSession?.roundsPlayed ?? 0) + sessionHistory.reduce(0, { $0 + $1.roundsPlayed }) else {
return
}
// Restore balance
// Restore balance and sessions
self.balance = cloudData.balance
self.currentSession = cloudData.currentSession
self.sessionHistory = cloudData.sessionHistory
self.sessionRoundHistories = cloudData.sessionRoundHistories
// Restore round history
self.roundHistory = cloudData.roundHistory.compactMap { saved in
guard let result = GameResult(persistenceKey: saved.result) else { return nil }
return RoundResult(
result: result,
playerValue: saved.playerValue,
bankerValue: saved.bankerValue,
playerPair: saved.playerPair,
bankerPair: saved.bankerPair
)
// Restore round history for road map display
self.roundHistory = cloudData.currentSessionRoundHistory.compactMap { saved in
saved.toRoundResult()
}
}
@ -407,55 +435,59 @@ final class GameState {
/// Loads saved game data from iCloud/local storage.
private func loadSavedGame() {
let savedData = persistence.data
let data = persistence.load()
self.balance = data.balance
self.currentSession = data.currentSession
self.sessionHistory = data.sessionHistory
self.sessionRoundHistories = data.sessionRoundHistories
// Only restore if there's saved progress
guard savedData.roundsPlayed > 0 else { return }
// Restore round history for road map display
self.roundHistory = data.currentSessionRoundHistory.compactMap { saved in
saved.toRoundResult()
}
// Restore balance
self.balance = savedData.balance
// Restore round history (convert saved to RoundResult)
self.roundHistory = savedData.roundHistory.compactMap { saved in
guard let result = GameResult(persistenceKey: saved.result) else { return nil }
return RoundResult(
result: result,
playerValue: saved.playerValue,
bankerValue: saved.bankerValue,
playerPair: saved.playerPair,
bankerPair: saved.bankerPair
)
CasinoDesign.debugLog("📂 Loaded game data:")
CasinoDesign.debugLog(" - balance: \(data.balance)")
CasinoDesign.debugLog(" - currentSession: \(data.currentSession?.id.uuidString ?? "none")")
CasinoDesign.debugLog(" - sessionHistory count: \(data.sessionHistory.count)")
CasinoDesign.debugLog(" - roundHistory count: \(roundHistory.count)")
CasinoDesign.debugLog(" - sessionRoundHistories count: \(sessionRoundHistories.count)")
if let session = data.currentSession {
CasinoDesign.debugLog(" - current session rounds: \(session.roundsPlayed), duration: \(session.duration)s")
}
}
/// Saves current game state to iCloud/local storage.
private func saveGame(netWinnings: Int = 0) {
var data = persistence.data
func saveGameData() {
// Update current session before saving
if var session = currentSession {
session.endingBalance = balance
// Keep session's game style in sync with current settings
session.gameStyle = currentGameStyle
currentSession = session
// Update balance
data.balance = balance
// Update statistics
data.totalWinnings += netWinnings
if netWinnings > data.biggestWin {
data.biggestWin = netWinnings
// Always keep the current session's round history in the archive
// This ensures it's available when the session ends and becomes historical
if !roundHistory.isEmpty {
let savedHistory = roundHistory.map { SavedRoundResult(from: $0) }
sessionRoundHistories[session.id.uuidString] = savedHistory
}
if netWinnings < 0 && abs(netWinnings) > data.biggestLoss {
data.biggestLoss = abs(netWinnings)
}
// Update round history from current session
data.roundHistory = roundHistory.enumerated().map { index, round in
// Try to get existing saved result for net winnings
if index < data.roundHistory.count {
return data.roundHistory[index]
}
// New round - calculate net winnings from betResults if available
let netForRound = betResults.reduce(0) { $0 + $1.payout }
return SavedRoundResult(from: round, netWinnings: index == roundHistory.count - 1 ? netWinnings : netForRound)
}
// Convert round history for persistence
let savedRoundHistory = roundHistory.map { SavedRoundResult(from: $0) }
let data = BaccaratGameData(
lastModified: Date(),
balance: balance,
currentSession: currentSession,
sessionHistory: sessionHistory,
currentSessionRoundHistory: savedRoundHistory,
sessionRoundHistories: sessionRoundHistories
)
persistence.save(data)
CasinoDesign.debugLog("💾 Saved game data - session rounds: \(currentSession?.roundsPlayed ?? 0), road history: \(roundHistory.count)")
}
/// Whether iCloud sync is available.
@ -559,6 +591,22 @@ final class GameState {
return currentAmount + amount <= maxBet
}
/// The minimum bet level across all bet types that can accept more chips.
/// 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 {
// All bet types are always available in Baccarat
let allBetTypes: [BetType] = [
.player, .banker, .tie,
.playerPair, .bankerPair,
.dragonBonusPlayer, .dragonBonusBanker
]
// Return the minimum bet amount across all bet types
// so chips stay enabled if any bet type can accept more
return allBetTypes.map { betAmount(for: $0) }.min() ?? 0
}
// MARK: - Betting Actions
/// Places a bet of the specified amount on the given bet type.
@ -748,10 +796,17 @@ final class GameState {
playerHadPair = engine.playerHasPair
bankerHadPair = engine.bankerHasPair
// Check for naturals
let isNatural = engine.playerHand.isNatural || engine.bankerHand.isNatural
// Calculate and apply payouts, track individual results
var totalWinnings = 0
var results: [BetResult] = []
// Track dragon bonus wins
var dragonPlayerWon = false
var dragonBankerWon = false
for bet in currentBets {
let payout = engine.calculatePayout(bet: bet, result: result)
totalWinnings += payout
@ -759,6 +814,14 @@ final class GameState {
// Track individual bet result
results.append(BetResult(type: bet.type, amount: bet.amount, payout: payout))
// Track dragon bonus wins
if bet.type == .dragonBonusPlayer && payout > 0 {
dragonPlayerWon = true
}
if bet.type == .dragonBonusBanker && payout > 0 {
dragonBankerWon = true
}
// Return original bet if not a loss
if payout >= 0 {
balance += bet.amount
@ -773,6 +836,40 @@ final class GameState {
betResults = results
lastWinnings = totalWinnings
// Determine round outcome for session stats
let isWin = totalWinnings > 0
let isLoss = totalWinnings < 0
let outcome: RoundOutcome = isWin ? .win : (isLoss ? .lose : .push)
// Capture values for closure
let roundBetAmount = totalBetAmount
let wasPlayerWin = result == .playerWins
let wasBankerWin = result == .bankerWins
let wasTie = result == .tie
let hadPlayerPair = playerHadPair
let hadBankerPair = bankerHadPair
// Record round in session using CasinoKit protocol
recordSessionRound(
winnings: totalWinnings,
betAmount: roundBetAmount,
outcome: outcome
) { stats in
// Update Baccarat-specific stats
if isNatural { stats.naturals += 1 }
if wasPlayerWin { stats.playerWins += 1 }
if wasBankerWin { stats.bankerWins += 1 }
if wasTie { stats.ties += 1 }
if hadPlayerPair { stats.playerPairs += 1 }
if hadBankerPair { stats.bankerPairs += 1 }
if dragonPlayerWon { stats.dragonBonusPlayerWins += 1 }
if dragonBankerWon { stats.dragonBonusBankerWins += 1 }
}
CasinoDesign.debugLog("📊 Session stats update:")
CasinoDesign.debugLog(" - roundsPlayed: \(currentSession?.roundsPlayed ?? 0)")
CasinoDesign.debugLog(" - duration: \(currentSession?.duration ?? 0) seconds")
// Play result sound based on MAIN BET outcome (not total winnings)
// This way winning the main hand plays win sound even if side bets lost
let mainBetResult = results.first(where: { $0.type == .player || $0.type == .banker })
@ -801,7 +898,7 @@ final class GameState {
}
}
// Record result in history
// Record result in history (for road map)
roundHistory.append(RoundResult(
result: result,
playerValue: playerHandValue,
@ -810,8 +907,8 @@ final class GameState {
bankerPair: bankerHadPair
))
// Save game state to iCloud/local
saveGame(netWinnings: totalWinnings)
// Save game data to iCloud
saveGameData()
// Show result banner - stays until user taps New Round
showResultBanner = true
@ -851,41 +948,91 @@ final class GameState {
}
}
/// Resets the game to initial state with current settings.
func resetGame() {
// MARK: - Session Cleanup Hooks
/// Clean up round history when deleting a session.
func onWillDeleteSession(id: UUID) {
sessionRoundHistories.removeValue(forKey: id.uuidString)
}
/// Clean up all round histories when deleting all sessions.
func onWillDeleteAllSessions() {
sessionRoundHistories.removeAll()
}
// MARK: - Session Round History
/// Gets round history for a specific session.
func roundHistory(for session: BaccaratSession) -> [RoundResult] {
// If it's the current session, return the live round history
if session.id == currentSession?.id {
return roundHistory
}
// Otherwise look up from archived histories
guard let savedHistory = sessionRoundHistories[session.id.uuidString] else {
return []
}
return savedHistory.compactMap { $0.toRoundResult() }
}
// MARK: - SessionManagedGame Implementation
/// Resets game-specific state when starting a new session.
func resetForNewSession() {
// Note: Round history is already archived during saveGameData() calls
// Just clear the local round history for the new session
roundHistory = []
engine = BaccaratEngine(deckCount: settings.deckCount.rawValue)
balance = settings.startingBalance
newRoundInternal()
}
/// Internal new round reset (without sound).
private func newRoundInternal() {
currentBets = []
currentPhase = .betting
lastResult = nil
lastWinnings = 0
visiblePlayerCards = []
visibleBankerCards = []
playerCardsFaceUp = []
bankerCardsFaceUp = []
roundHistory = []
isAnimating = false
showResultBanner = false
lastResult = nil
lastWinnings = 0
playerHadPair = false
bankerHadPair = false
betResults = []
currentPhase = .betting
}
// Save the reset state (keeps lifetime stats, resets balance and session history)
saveGame()
/// Aggregated Baccarat-specific stats from all sessions.
var aggregatedBaccaratStats: BaccaratStats {
allSessions.aggregatedBaccaratStats()
}
// MARK: - Game Reset
/// Resets the entire game (keeps statistics).
/// Uses CasinoKit's performResetGame() which properly handles session ending.
func resetGame() {
performResetGame()
// Note: newRoundInternal() is called by resetForNewSession()
// Play new game sound
sound.playNewRound()
}
/// Completely clears all saved data and starts fresh (including lifetime stats).
/// Completely clears all saved data and starts fresh.
func clearAllData() {
persistence.reset()
resetGame()
}
balance = settings.startingBalance
currentSession = nil
sessionHistory = []
sessionRoundHistories = [:]
roundHistory = []
startNewSession()
newRoundInternal()
/// Returns lifetime statistics from saved data.
var lifetimeStats: BaccaratGameData {
persistence.data
// Play new game sound
sound.playNewRound()
}
/// Applies new settings (call after settings change).

View File

@ -0,0 +1,111 @@
//
// BaccaratStats.swift
// Baccarat
//
// Baccarat-specific statistics that conform to CasinoKit's GameSpecificStats.
//
import Foundation
import SwiftUI
import CasinoKit
/// Baccarat-specific session statistics.
/// Tracks naturals, banker/player wins, ties, pairs, and dragon bonus wins.
public struct BaccaratStats: GameSpecificStats {
/// Number of natural hands (8 or 9 on initial deal).
public var naturals: Int = 0
/// Number of banker win rounds.
public var bankerWins: Int = 0
/// Number of player win rounds.
public var playerWins: Int = 0
/// Number of tie rounds.
public var ties: Int = 0
/// Number of player pair occurrences.
public var playerPairs: Int = 0
/// Number of banker pair occurrences.
public var bankerPairs: Int = 0
/// Number of dragon bonus wins (player side).
public var dragonBonusPlayerWins: Int = 0
/// Number of dragon bonus wins (banker side).
public var dragonBonusBankerWins: Int = 0
// MARK: - GameSpecificStats
public init() {}
/// Display items for the statistics UI.
public var displayItems: [StatDisplayItem] {
[
StatDisplayItem(
icon: "sparkles",
iconColor: .yellow,
label: String(localized: "Naturals"),
value: "\(naturals)",
valueColor: .yellow
),
StatDisplayItem(
icon: "person.fill",
iconColor: .blue,
label: String(localized: "Player Wins"),
value: "\(playerWins)",
valueColor: .blue
),
StatDisplayItem(
icon: "building.columns.fill",
iconColor: .red,
label: String(localized: "Banker Wins"),
value: "\(bankerWins)",
valueColor: .red
),
StatDisplayItem(
icon: "equal.circle.fill",
iconColor: .green,
label: String(localized: "Ties"),
value: "\(ties)",
valueColor: .green
),
StatDisplayItem(
icon: "suit.diamond.fill",
iconColor: .purple,
label: String(localized: "Pairs"),
value: "\(playerPairs + bankerPairs)",
valueColor: .purple
)
]
}
}
// MARK: - Aggregation Extension
extension Array where Element == GameSession<BaccaratStats> {
/// Aggregates Baccarat-specific stats from all sessions.
func aggregatedBaccaratStats() -> BaccaratStats {
var combined = BaccaratStats()
for session in self {
combined.naturals += session.gameStats.naturals
combined.bankerWins += session.gameStats.bankerWins
combined.playerWins += session.gameStats.playerWins
combined.ties += session.gameStats.ties
combined.playerPairs += session.gameStats.playerPairs
combined.bankerPairs += session.gameStats.bankerPairs
combined.dragonBonusPlayerWins += session.gameStats.dragonBonusPlayerWins
combined.dragonBonusBankerWins += session.gameStats.dragonBonusBankerWins
}
return combined
}
}
// MARK: - Type Aliases for Convenience
/// Baccarat session type alias.
public typealias BaccaratSession = GameSession<BaccaratStats>

View File

@ -0,0 +1,30 @@
//
// Card+Baccarat.swift
// Baccarat
//
// Baccarat-specific card value extensions.
//
import CasinoKit
extension Rank {
/// The baccarat point value of this rank.
/// Ace = 1, 2-9 = face value, 10/J/Q/K = 0.
var baccaratValue: Int {
switch self {
case .ace: return 1
case .two, .three, .four, .five, .six, .seven, .eight, .nine:
return rawValue
case .ten, .jack, .queen, .king:
return 0
}
}
}
extension Card {
/// The baccarat point value of this card.
var baccaratValue: Int {
rank.baccaratValue
}
}

View File

@ -7,6 +7,7 @@
import Foundation
import SwiftUI
import CasinoKit
/// The number of decks available for the shoe.
enum DeckCount: Int, CaseIterable, Identifiable {
@ -33,65 +34,13 @@ enum DeckCount: Int, CaseIterable, Identifiable {
}
}
/// Preset table limits for betting.
enum TableLimits: String, CaseIterable, Identifiable {
case casual = "casual"
case low = "low"
case medium = "medium"
case high = "high"
case vip = "vip"
// TableLimits is now provided by CasinoKit
var id: String { rawValue }
var displayName: String {
switch self {
case .casual: return "Casual"
case .low: return "Low Stakes"
case .medium: return "Medium Stakes"
case .high: return "High Stakes"
case .vip: return "VIP"
}
}
var minBet: Int {
switch self {
case .casual: return 5
case .low: return 10
case .medium: return 25
case .high: return 100
case .vip: return 500
}
}
var maxBet: Int {
switch self {
case .casual: return 500
case .low: return 1_000
case .medium: return 5_000
case .high: return 10_000
case .vip: return 50_000
}
}
var description: String {
"$\(minBet) - $\(maxBet.formatted())"
}
var detailedDescription: String {
switch self {
case .casual: return "Perfect for learning"
case .low: return "Standard mini baccarat"
case .medium: return "Regular casino table"
case .high: return "High roller table"
case .vip: return "Exclusive VIP room"
}
}
}
/// Observable settings class for game configuration.
/// Observable settings class for Baccarat configuration.
/// Conforms to GameSettingsProtocol for shared settings behavior.
@Observable
@MainActor
final class GameSettings {
final class GameSettings: GameSettingsProtocol {
// MARK: - Deck Settings
/// Number of decks in the shoe.
@ -122,8 +71,8 @@ final class GameSettings {
/// Whether to show dealing animations.
var showAnimations: Bool = true
/// Speed of card dealing (1.0 = normal, 0.5 = fast, 2.0 = slow)
var dealingSpeed: Double = 1.0
/// Speed of card dealing (uses CasinoDesign.DealingSpeed constants)
var dealingSpeed: Double = CasinoDesign.DealingSpeed.normal
// MARK: - Display Settings
@ -368,7 +317,7 @@ final class GameSettings {
tableLimits = .casual
startingBalance = 1_000
showAnimations = true
dealingSpeed = 1.0
dealingSpeed = CasinoDesign.DealingSpeed.normal
showCardsRemaining = true
showHistory = true
showHints = true

File diff suppressed because it is too large Load Diff

View File

@ -9,75 +9,54 @@ import Foundation
import CasinoKit
/// Persisted data for Baccarat game.
public struct BaccaratGameData: PersistableGameData {
public struct BaccaratGameData: PersistableGameData, SessionPersistable {
// MARK: - PersistableGameData
public static let gameIdentifier = "baccarat"
public var roundsPlayed: Int {
roundHistory.count
// Total rounds from all sessions
let historicalRounds = sessionHistory.reduce(0) { $0 + $1.roundsPlayed }
let currentRounds = currentSession?.roundsPlayed ?? 0
return historicalRounds + currentRounds
}
public static var empty: BaccaratGameData {
BaccaratGameData(
balance: 10_000,
roundHistory: [],
totalWinnings: 0,
biggestWin: 0,
biggestLoss: 0,
lastModified: Date()
lastModified: Date(),
balance: 1_000,
currentSession: nil,
sessionHistory: [],
currentSessionRoundHistory: [],
sessionRoundHistories: [:]
)
}
// MARK: - Game Data
/// Current chip balance.
public var balance: Int
/// History of all rounds played.
public var roundHistory: [SavedRoundResult]
// MARK: - Lifetime Statistics
/// Total net winnings (can be negative).
public var totalWinnings: Int
/// Biggest single-round win.
public var biggestWin: Int
/// Biggest single-round loss (stored as positive number).
public var biggestLoss: Int
/// Last time data was modified (required by PersistableGameData).
public var lastModified: Date
// MARK: - Computed Stats
/// Current chip balance.
public var balance: Int
/// Number of Player wins.
public var playerWins: Int {
roundHistory.filter { $0.result == "player" }.count
}
/// The currently active session (nil if no session started).
public var currentSession: BaccaratSession?
/// Number of Banker wins.
public var bankerWins: Int {
roundHistory.filter { $0.result == "banker" }.count
}
/// History of completed sessions.
public var sessionHistory: [BaccaratSession]
/// Number of Tie games.
public var tieGames: Int {
roundHistory.filter { $0.result == "tie" }.count
}
/// Round history for the current session (for road map display).
/// This is cleared when a new session starts.
public var currentSessionRoundHistory: [SavedRoundResult]
/// Win rate percentage.
public var winRate: Double {
guard roundsPlayed > 0 else { return 0 }
let wins = roundHistory.filter { $0.netWinnings > 0 }.count
return Double(wins) / Double(roundsPlayed) * 100
}
/// Round histories for completed sessions (keyed by session ID string).
/// Used to display road maps when viewing historical sessions.
public var sessionRoundHistories: [String: [SavedRoundResult]]
}
/// Codable round result for persistence.
/// Codable round result for persistence (for road map display).
public struct SavedRoundResult: Codable, Identifiable, Sendable {
public let id: UUID
public let result: String // "player", "banker", "tie"
@ -87,7 +66,6 @@ public struct SavedRoundResult: Codable, Identifiable, Sendable {
public let bankerPair: Bool
public let isNatural: Bool
public let timestamp: Date
public let netWinnings: Int
public init(
id: UUID = UUID(),
@ -97,8 +75,7 @@ public struct SavedRoundResult: Codable, Identifiable, Sendable {
playerPair: Bool,
bankerPair: Bool,
isNatural: Bool,
timestamp: Date = Date(),
netWinnings: Int
timestamp: Date = Date()
) {
self.id = id
self.result = result
@ -108,15 +85,14 @@ public struct SavedRoundResult: Codable, Identifiable, Sendable {
self.bankerPair = bankerPair
self.isNatural = isNatural
self.timestamp = timestamp
self.netWinnings = netWinnings
}
}
// MARK: - Conversion from RoundResult
extension SavedRoundResult {
/// Creates a SavedRoundResult from a RoundResult and net winnings.
init(from roundResult: RoundResult, netWinnings: Int) {
/// Creates a SavedRoundResult from a RoundResult.
init(from roundResult: RoundResult) {
self.id = roundResult.id
self.result = roundResult.result.persistenceKey
self.playerValue = roundResult.playerValue
@ -125,7 +101,18 @@ extension SavedRoundResult {
self.bankerPair = roundResult.bankerPair
self.isNatural = roundResult.isNatural
self.timestamp = roundResult.timestamp
self.netWinnings = netWinnings
}
/// Converts back to RoundResult for display.
func toRoundResult() -> RoundResult? {
guard let gameResult = GameResult(persistenceKey: result) else { return nil }
return RoundResult(
result: gameResult,
playerValue: playerValue,
bankerValue: bankerValue,
playerPair: playerPair,
bankerPair: bankerPair
)
}
}
@ -151,4 +138,3 @@ extension GameResult {
}
}
}

View File

@ -0,0 +1,40 @@
//
// BrandingConfig+Baccarat.swift
// Baccarat
//
// Baccarat-specific branding configurations for AppIconView and LaunchScreenView.
//
import SwiftUI
import CasinoKit
extension AppIconConfig {
/// Baccarat game icon configuration.
static let baccarat = AppIconConfig(
title: "BACCARAT",
iconSymbol: "suit.spade.fill"
)
}
extension LaunchScreenConfig {
/// Baccarat game launch screen configuration.
static let baccarat = LaunchScreenConfig(
title: "BACCARAT",
tagline: "The Classic Casino Card Game",
iconSymbols: ["suit.spade.fill", "suit.heart.fill"]
)
}
// MARK: - For Development Preview Comparison
extension AppIconConfig {
/// Blackjack config for side-by-side comparison in dev previews.
static let blackjack = AppIconConfig(
title: "BLACKJACK",
subtitle: "21",
iconSymbol: "suit.club.fill",
primaryColor: Color(red: 0.05, green: 0.35, blue: 0.15),
secondaryColor: Color(red: 0.03, green: 0.2, blue: 0.1)
)
}

View File

@ -131,11 +131,12 @@ struct GameTableView: View {
RulesHelpView()
}
.sheet(isPresented: $showStats) {
StatisticsSheetView(results: state.roundHistory)
StatisticsSheetView(state: state)
}
.sheet(isPresented: $showWelcome) {
WelcomeSheet(
gameName: "Baccarat",
gameEmoji: "🎴",
features: [
WelcomeFeature(
icon: "hand.raised.fill",
@ -148,9 +149,9 @@ struct GameTableView: View {
description: String(localized: "Road maps show game history and trends")
),
WelcomeFeature(
icon: "dollarsign.circle",
title: String(localized: "Practice Free"),
description: String(localized: "Start with $1,000 and play risk-free")
icon: "clock.badge.checkmark.fill",
title: String(localized: "Track Sessions"),
description: String(localized: "See detailed stats for each play session, just like at a real casino")
),
WelcomeFeature(
icon: "gearshape.fill",
@ -158,21 +159,17 @@ struct GameTableView: View {
description: String(localized: "Change table limits and display options")
)
],
onStartTutorial: {
showWelcome = false
state.onboarding.completeWelcome()
checkOnboardingHints()
},
onStartPlaying: {
// Mark all hints as shown FIRST so they don't appear
state.onboarding.markHintShown("bettingZone")
state.onboarding.markHintShown("dealButton")
state.onboarding.markHintShown("firstResult")
state.onboarding.completeWelcome()
showWelcome = false
}
onboarding: state.onboarding,
onDismiss: { showWelcome = false },
onShowHints: checkOnboardingHints
)
}
.onChange(of: showWelcome) { wasShowing, isShowing in
// Handle swipe-down dismissal: treat as "Start Playing" (no tooltips)
if wasShowing && !isShowing && !state.onboarding.hasCompletedWelcome {
state.onboarding.skipOnboarding()
}
}
.onChange(of: state.totalBetAmount) { _, newTotal in
if newTotal > 0, state.onboarding.shouldShowHint("dealButton") {
showDealHintWithDelay()
@ -356,7 +353,7 @@ struct GameTableView: View {
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
currentBet: state.totalBetAmount,
currentBet: state.minBetForChipSelector,
maxBet: state.maxBet
)
.transition(.opacity.combined(with: .move(edge: .bottom)))
@ -467,7 +464,7 @@ struct GameTableView: View {
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
currentBet: state.totalBetAmount,
currentBet: state.minBetForChipSelector,
maxBet: state.maxBet
)
.transition(.opacity.combined(with: .move(edge: .bottom)))

View File

@ -228,7 +228,7 @@ struct SettingsView: View {
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text("\(gameState.lifetimeStats.roundsPlayed)")
Text("\(gameState.aggregatedStats.totalRoundsPlayed)")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
@ -240,7 +240,7 @@ struct SettingsView: View {
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
let winnings = gameState.lifetimeStats.totalWinnings
let winnings = gameState.aggregatedStats.totalWinnings
Text(winnings >= 0 ? "+$\(winnings)" : "-$\(abs(winnings))")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(winnings >= 0 ? .green : .red)

View File

@ -2,299 +2,561 @@
// StatisticsSheetView.swift
// Baccarat
//
// Detailed statistics and scoreboard view.
// Game statistics with session history and per-style stats.
//
import SwiftUI
import CasinoKit
/// A sheet that displays detailed game statistics and Big Road scoreboard.
struct StatisticsSheetView: View {
let results: [RoundResult]
@Bindable var state: GameState
@Environment(\.dismiss) private var dismiss
// MARK: - Computed Statistics
private var totalRounds: Int { results.count }
private var playerWins: Int {
results.filter { $0.result == .playerWins }.count
}
private var bankerWins: Int {
results.filter { $0.result == .bankerWins }.count
}
private var tieCount: Int {
results.filter { $0.result == .tie }.count
}
private var playerPairs: Int {
results.filter { $0.playerPair }.count
}
private var bankerPairs: Int {
results.filter { $0.bankerPair }.count
}
private var naturals: Int {
results.filter { $0.isNatural }.count
}
private func percentage(_ count: Int) -> String {
guard totalRounds > 0 else { return "0%" }
let pct = Double(count) / Double(totalRounds) * 100
return String(format: "%.0f%%", pct)
}
@State private var selectedTab: StatisticsTab = .current
@State private var selectedSession: BaccaratSession?
var body: some View {
SheetContainerView(
title: String(localized: "Statistics"),
content: {
// Summary stats
summarySection
// Tab selector (from CasinoKit)
StatisticsTabSelector(selectedTab: $selectedTab)
// Win distribution
winDistributionSection
// Side bet frequency
sideBetSection
// Big Road display
bigRoadSection
},
onDone: {
dismiss()
// Content based on selected tab
switch selectedTab {
case .current:
currentSessionContent
case .global:
globalStatsContent
case .history:
sessionHistoryContent
}
},
onCancel: nil,
onDone: { dismiss() },
doneButtonText: String(localized: "Done")
)
.confirmationDialog(
String(localized: "End Session?"),
isPresented: $state.showEndSessionConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "End Session"), role: .destructive) {
// Dismiss first for responsive UI, then end session
dismiss()
Task { @MainActor in
state.endSessionAndStartNew()
}
}
Button(String(localized: "Cancel"), role: .cancel) {}
} message: {
if let session = state.currentSession {
Text(String(localized: "You played \(session.roundsPlayed) hands with a net result of \(SessionFormatter.formatMoney(session.netResult)). This session will be saved to your history."))
}
}
.sheet(item: $selectedSession) { session in
SessionDetailView(
session: session,
styleDisplayName: styleDisplayName(for: session.gameStyle),
roundHistory: state.roundHistory(for: session),
onDelete: {
state.deleteSession(id: session.id)
selectedSession = nil
}
// MARK: - Summary Section
private var summarySection: some View {
SheetSection(title: "SESSION SUMMARY", icon: "chart.pie.fill") {
HStack(spacing: Design.Spacing.xLarge) {
StatBox(
value: "\(totalRounds)",
label: String(localized: "Rounds"),
color: .white
)
StatBox(
value: "\(naturals)",
label: String(localized: "Naturals"),
color: .yellow
)
}
.frame(maxWidth: .infinity)
}
// MARK: - Tab Selector (uses CasinoKit.StatisticsTabSelector)
// MARK: - Current Session Content
@ViewBuilder
private var currentSessionContent: some View {
if let session = state.currentSession {
// Current session header
CurrentSessionHeader(
duration: session.duration,
roundsPlayed: session.roundsPlayed,
netResult: session.netResult,
onEndSession: {
state.showEndSessionConfirmation = true
}
)
.padding(.horizontal)
// Session stats
sessionStatsSection(session: session)
// Road displays for current session
bigRoadSection
roadMapSection
} else {
NoActiveSessionView()
}
}
// MARK: - Win Distribution Section
// MARK: - Global Stats Content
private var winDistributionSection: some View {
SheetSection(title: "WIN DISTRIBUTION", icon: "trophy.fill") {
private var globalStatsContent: some View {
let stats = state.aggregatedStats
let gameStats = state.aggregatedBaccaratStats
return Group {
// Summary section
SheetSection(title: String(localized: "ALL TIME SUMMARY"), icon: "globe") {
VStack(spacing: Design.Spacing.large) {
// Sessions overview
HStack(spacing: Design.Spacing.large) {
StatColumn(
value: "\(stats.totalSessions)",
label: String(localized: "Sessions")
)
StatColumn(
value: "\(stats.totalRoundsPlayed)",
label: String(localized: "Hands")
)
StatColumn(
value: SessionFormatter.formatPercent(stats.winRate),
label: String(localized: "Win Rate"),
valueColor: stats.winRate >= 50 ? .green : .orange
)
}
Divider().background(Color.white.opacity(Design.Opacity.hint))
// Financial summary
HStack(spacing: Design.Spacing.large) {
StatColumn(
value: SessionFormatter.formatMoney(stats.totalWinnings),
label: String(localized: "Net"),
valueColor: stats.totalWinnings >= 0 ? .green : .red
)
StatColumn(
value: SessionFormatter.formatDuration(stats.totalPlayTime),
label: String(localized: "Time")
)
}
}
}
// Baccarat-specific stats
SheetSection(title: String(localized: "GAME STATS"), icon: "suit.diamond.fill") {
VStack(spacing: Design.Spacing.medium) {
HStack(spacing: Design.Spacing.medium) {
WinStatView(
title: String(localized: "Player"),
count: playerWins,
percentage: percentage(playerWins),
ForEach(gameStats.displayItems) { item in
GameStatRow(item: item)
}
Divider().background(Color.white.opacity(Design.Opacity.hint))
// Win distribution
HStack(spacing: Design.Spacing.large) {
WinStatCompact(
label: String(localized: "Player"),
count: gameStats.playerWins,
color: .blue
)
WinStatView(
title: String(localized: "Tie"),
count: tieCount,
percentage: percentage(tieCount),
WinStatCompact(
label: String(localized: "Banker"),
count: gameStats.bankerWins,
color: .red
)
WinStatCompact(
label: String(localized: "Tie"),
count: gameStats.ties,
color: .green
)
}
}
}
WinStatView(
title: String(localized: "Banker"),
count: bankerWins,
percentage: percentage(bankerWins),
color: .red
// Session performance (from CasinoKit)
SheetSection(title: String(localized: "SESSION PERFORMANCE"), icon: "chart.bar.fill") {
SessionPerformanceSection(
winningSessions: stats.winningSessions,
losingSessions: stats.losingSessions,
bestSession: stats.bestSession,
worstSession: stats.worstSession
)
}
}
}
// Win bar visualization
if totalRounds > 0 {
WinDistributionBar(
playerWins: playerWins,
tieCount: tieCount,
bankerWins: bankerWins
// MARK: - Session History Content
private var sessionHistoryContent: some View {
Group {
if state.sessionHistory.isEmpty && state.currentSession == nil {
EmptyHistoryView()
} else {
LazyVStack(spacing: Design.Spacing.medium) {
// Current session at top if exists (taps go to Current tab)
if let current = state.currentSession {
Button {
withAnimation {
selectedTab = .current
}
} label: {
SessionSummaryRow(
styleDisplayName: styleDisplayName(for: current.gameStyle),
duration: current.duration,
roundsPlayed: current.roundsPlayed,
netResult: current.netResult,
startTime: current.startTime,
isActive: true,
endReason: nil
)
.frame(height: Design.Spacing.large)
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
}
.buttonStyle(.plain)
}
// Historical sessions - tap to view details, swipe to delete
ForEach(state.sessionHistory) { session in
Button {
selectedSession = session
} label: {
HStack {
SessionSummaryRow(
styleDisplayName: styleDisplayName(for: session.gameStyle),
duration: session.duration,
roundsPlayed: session.roundsPlayed,
netResult: session.netResult,
startTime: session.startTime,
isActive: false,
endReason: session.endReason
)
Image(systemName: "chevron.right")
.font(.system(size: Design.BaseFontSize.small, weight: .semibold))
.foregroundStyle(.white.opacity(Design.Opacity.light))
}
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
withAnimation {
state.deleteSession(id: session.id)
}
} label: {
Label(String(localized: "Delete"), systemImage: "trash")
}
}
}
}
.padding(.horizontal)
}
}
}
// MARK: - Side Bet Section
private var sideBetSection: some View {
SheetSection(title: "SIDE BET FREQUENCY", icon: "sparkles") {
HStack(spacing: Design.Spacing.xLarge) {
PairStatView(
title: String(localized: "P Pair"),
count: playerPairs,
percentage: percentage(playerPairs),
// MARK: - Session Stats Section
@ViewBuilder
private func sessionStatsSection(session: BaccaratSession) -> some View {
// Game stats section
SheetSection(title: String(localized: "GAME STATS"), icon: "gamecontroller.fill") {
VStack(spacing: Design.Spacing.large) {
// Rounds played
VStack(spacing: Design.Spacing.xSmall) {
Text(String(localized: "Rounds played"))
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
Text("\(session.roundsPlayed)")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
// Win/Loss/Push
HStack(spacing: Design.Spacing.medium) {
OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white)
OutcomeCircle(label: String(localized: "Lost"), count: session.losses, color: .red)
OutcomeCircle(label: String(localized: "Push"), count: session.pushes, color: .gray)
}
Divider().background(Color.white.opacity(Design.Opacity.hint))
// Game time and Baccarat-specific stats
StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration))
ForEach(session.gameStats.displayItems) { item in
GameStatRow(item: item)
}
Divider().background(Color.white.opacity(Design.Opacity.hint))
// Win distribution
HStack(spacing: Design.Spacing.large) {
WinStatCompact(
label: String(localized: "Player"),
count: session.gameStats.playerWins,
color: .blue
)
PairStatView(
title: String(localized: "B Pair"),
count: bankerPairs,
percentage: percentage(bankerPairs),
WinStatCompact(
label: String(localized: "Banker"),
count: session.gameStats.bankerWins,
color: .red
)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Big Road Section
private var bigRoadSection: some View {
SheetSection(title: "BIG ROAD", icon: "chart.bar.xaxis") {
if results.isEmpty {
Text(String(localized: "No rounds played yet"))
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
.frame(maxWidth: .infinity)
.padding(.vertical, Design.Spacing.xLarge)
} else {
BigRoadView(results: results)
.frame(height: Design.Size.bigRoadHeight)
}
}
}
}
// MARK: - Supporting Views
/// A box displaying a single statistic.
private struct StatBox: View {
let value: String
let label: String
let color: Color
var body: some View {
VStack(spacing: Design.Spacing.xSmall) {
Text(value)
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(color)
Text(label)
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
}
.frame(minWidth: Design.Size.statBoxMinWidth)
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.overlay))
WinStatCompact(
label: String(localized: "Tie"),
count: session.gameStats.ties,
color: .green
)
}
}
}
// Chips stats section (from CasinoKit)
SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") {
ChipsStatsSection(
totalWinnings: session.totalWinnings,
biggestWin: session.biggestWin,
biggestLoss: session.biggestLoss,
totalBetAmount: session.totalBetAmount,
averageBet: session.roundsPlayed > 0 ? session.averageBet : nil,
biggestBet: session.biggestBet
)
}
}
// MARK: - Road Sections
private var bigRoadSection: some View {
SheetSection(title: String(localized: "BIG ROAD"), icon: "chart.bar.xaxis") {
if state.roundHistory.isEmpty {
emptyRoadPlaceholder
} else {
BigRoadView(results: state.roundHistory)
.frame(height: Size.bigRoadHeight)
}
}
}
private var roadMapSection: some View {
SheetSection(title: String(localized: "HISTORY"), icon: "clock.arrow.circlepath") {
if state.roundHistory.isEmpty {
emptyRoadPlaceholder
} else {
// Horizontal display matching what's shown during gameplay
ScrollView(.horizontal) {
HStack(spacing: Design.Spacing.xSmall) {
ForEach(state.roundHistory) { result in
RoadDot(
result: result.result,
dotSize: Size.roadDotSize,
hasPair: result.hasPair,
isNatural: result.isNatural
)
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
.scrollIndicators(.hidden)
}
}
}
private var emptyRoadPlaceholder: some View {
Text(String(localized: "No rounds played yet"))
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, Design.Spacing.xLarge)
}
// MARK: - Helpers
private func styleDisplayName(for rawValue: String) -> String {
// Return the deck count display name
DeckCount(rawValue: Int(rawValue) ?? 8)?.displayName ?? rawValue
}
}
/// A win stat display with count and percentage.
private struct WinStatView: View {
let title: String
// MARK: - Statistics Tab & Supporting Views
// StatisticsTab, StatColumn, OutcomeCircle, StatRow, ChipStatRow are now provided by CasinoKit
// MARK: - Baccarat-Specific Views
/// Compact win distribution indicator for Player/Banker/Tie.
private struct WinStatCompact: View {
let label: String
let count: Int
let percentage: String
let color: Color
private let indicatorSize: CGFloat = 24
var body: some View {
VStack(spacing: Design.Spacing.xSmall) {
VStack(spacing: CasinoDesign.Spacing.xSmall) {
Circle()
.fill(color)
.frame(width: Design.Size.winIndicatorSize, height: Design.Size.winIndicatorSize)
.frame(width: indicatorSize, height: indicatorSize)
Text("\(count)")
.font(.system(size: Design.BaseFontSize.title, weight: .bold))
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .bold))
.foregroundStyle(.white)
Text(percentage)
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
Text(title)
.font(.system(size: Design.BaseFontSize.small))
Text(label)
.font(.system(size: CasinoDesign.BaseFontSize.small))
.foregroundStyle(color)
}
.frame(maxWidth: .infinity)
}
}
/// A pair stat display.
private struct PairStatView: View {
let title: String
let count: Int
let percentage: String
let color: Color
// MARK: - Session Detail View
private struct SessionDetailView: View {
let session: BaccaratSession
let styleDisplayName: String
let roundHistory: [RoundResult]
let onDelete: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var showDeleteConfirmation = false
var body: some View {
SheetContainerView(
title: styleDisplayName,
content: {
// Session header info (from CasinoKit)
SessionDetailHeader(
startTime: session.startTime,
endReason: session.endReason,
netResult: session.netResult,
winRate: session.winRate
)
// Game stats section
SheetSection(title: String(localized: "GAME STATS"), icon: "gamecontroller.fill") {
VStack(spacing: Design.Spacing.large) {
// Rounds played
VStack(spacing: Design.Spacing.xSmall) {
Text("\(count)")
.font(.system(size: Design.BaseFontSize.title, weight: .bold))
.foregroundStyle(color)
Text(String(localized: "Rounds played"))
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
Text("\(session.roundsPlayed)")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
Text(percentage)
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
// Win/Loss/Push (from CasinoKit)
HStack(spacing: Design.Spacing.medium) {
OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white)
OutcomeCircle(label: String(localized: "Lost"), count: session.losses, color: .red)
OutcomeCircle(label: String(localized: "Push"), count: session.pushes, color: .gray)
}
Text(title)
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
Divider().background(Color.white.opacity(Design.Opacity.hint))
// Game time (from CasinoKit)
StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration))
// Baccarat-specific stats
ForEach(session.gameStats.displayItems) { item in
GameStatRow(item: item)
}
Divider().background(Color.white.opacity(Design.Opacity.hint))
// Win distribution (Baccarat-specific)
HStack(spacing: Design.Spacing.large) {
WinStatCompact(
label: String(localized: "Player"),
count: session.gameStats.playerWins,
color: .blue
)
WinStatCompact(
label: String(localized: "Banker"),
count: session.gameStats.bankerWins,
color: .red
)
WinStatCompact(
label: String(localized: "Tie"),
count: session.gameStats.ties,
color: .green
)
}
}
}
// Chips stats section (from CasinoKit)
SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") {
ChipsStatsSection(
totalWinnings: session.totalWinnings,
biggestWin: session.biggestWin,
biggestLoss: session.biggestLoss,
totalBetAmount: session.totalBetAmount,
averageBet: session.roundsPlayed > 0 ? session.averageBet : nil,
biggestBet: session.biggestBet
)
}
// Balance section (from CasinoKit)
SheetSection(title: String(localized: "BALANCE"), icon: "banknote.fill") {
BalanceSection(
startingBalance: session.startingBalance,
endingBalance: session.endingBalance,
netResult: session.netResult
)
}
// Big Road section (Baccarat-specific)
if !roundHistory.isEmpty {
SheetSection(title: String(localized: "BIG ROAD"), icon: "chart.bar.xaxis") {
BigRoadView(results: roundHistory)
.frame(height: Size.bigRoadHeight)
}
// History road section
SheetSection(title: String(localized: "HISTORY"), icon: "clock.arrow.circlepath") {
ScrollView(.horizontal) {
HStack(spacing: Design.Spacing.xSmall) {
ForEach(roundHistory) { result in
RoadDot(
result: result.result,
dotSize: Size.roadDotSize,
hasPair: result.hasPair,
isNatural: result.isNatural
)
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
.scrollIndicators(.hidden)
}
}
// Delete button (from CasinoKit)
DeleteSessionButton {
showDeleteConfirmation = true
}
},
onCancel: nil,
onDone: { dismiss() },
doneButtonText: String(localized: "Done")
)
.confirmationDialog(
String(localized: "Delete Session?"),
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "Delete"), role: .destructive) {
onDelete()
dismiss()
}
Button(String(localized: "Cancel"), role: .cancel) {}
} message: {
Text(String(localized: "This will permanently remove this session from your history."))
}
}
}
/// A horizontal bar showing win distribution.
private struct WinDistributionBar: View {
let playerWins: Int
let tieCount: Int
let bankerWins: Int
// MARK: - Big Road View
private var total: Int { playerWins + tieCount + bankerWins }
var body: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
if playerWins > 0 {
Rectangle()
.fill(Color.blue)
.frame(width: geometry.size.width * CGFloat(playerWins) / CGFloat(total))
}
if tieCount > 0 {
Rectangle()
.fill(Color.green)
.frame(width: geometry.size.width * CGFloat(tieCount) / CGFloat(total))
}
if bankerWins > 0 {
Rectangle()
.fill(Color.red)
.frame(width: geometry.size.width * CGFloat(bankerWins) / CGFloat(total))
}
}
}
}
}
/// The Big Road scoreboard - a grid showing result patterns.
/// Results are arranged in columns, with each column representing a streak of same results.
private struct BigRoadView: View {
let results: [RoundResult]
private let maxRows = 6
private let cellSize: CGFloat = Design.Size.bigRoadCellSize
private let cellSize: CGFloat = Size.bigRoadCellSize
/// Convert results into columns for Big Road display.
private var columns: [[RoundResult]] {
@ -303,11 +565,10 @@ private struct BigRoadView: View {
var lastResult: GameResult?
for result in results {
// Skip ties for column tracking (ties go in the current column)
let currentResult = result.result
if currentResult == .tie {
// Ties don't start new columns, they go with the current streak
// Ties don't start new columns
if !currentCol.isEmpty {
currentCol.append(result)
} else if !cols.isEmpty {
@ -316,11 +577,9 @@ private struct BigRoadView: View {
currentCol.append(result)
}
} else if lastResult == nil || currentResult == lastResult {
// Same as last or first result - continue column
currentCol.append(result)
lastResult = currentResult
} else {
// Different result - start new column
if !currentCol.isEmpty {
cols.append(currentCol)
}
@ -329,7 +588,6 @@ private struct BigRoadView: View {
}
}
// Add remaining column
if !currentCol.isEmpty {
cols.append(currentCol)
}
@ -346,11 +604,10 @@ private struct BigRoadView: View {
BigRoadCell(result: result)
}
// If column has more than maxRows, show overflow count
if column.count > maxRows {
Text("+\(column.count - maxRows)")
.font(.system(size: Design.BaseFontSize.xxSmall))
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Spacer(minLength: 0)
@ -361,17 +618,16 @@ private struct BigRoadView: View {
}
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.overlay))
.fill(Color.black.opacity(Design.Opacity.light))
)
.scrollIndicators(.hidden)
}
}
/// A single cell in the Big Road display.
private struct BigRoadCell: View {
let result: RoundResult
private let cellSize: CGFloat = Design.Size.bigRoadCellSize
private let cellSize: CGFloat = Size.bigRoadCellSize
private var color: Color {
switch result.result {
@ -383,12 +639,10 @@ private struct BigRoadCell: View {
var body: some View {
ZStack {
// Main circle
Circle()
.stroke(color, lineWidth: Design.LineWidth.medium)
.frame(width: cellSize, height: cellSize)
// Pair indicator (small dot at bottom)
if result.hasPair {
Circle()
.fill(Color.yellow)
@ -396,7 +650,6 @@ private struct BigRoadCell: View {
.offset(y: cellSize * 0.3)
}
// Natural indicator (small dot at top)
if result.isNatural {
Circle()
.fill(Color.white)
@ -404,7 +657,6 @@ private struct BigRoadCell: View {
.offset(y: -cellSize * 0.3)
}
// Tie diagonal line if it's a tie
if result.result == .tie {
Rectangle()
.fill(color)
@ -415,27 +667,21 @@ private struct BigRoadCell: View {
}
}
// MARK: - Design Constants Extensions
// MARK: - Local Size Constants
extension Design.Size {
static let bigRoadHeight: CGFloat = 200
private enum Size {
static let outcomeCircleSize: CGFloat = 48
static let outcomeCircleInner: CGFloat = 24
static let statIconWidth: CGFloat = 32
static let chipIconSize: CGFloat = 28
static let bigRoadHeight: CGFloat = 180
static let bigRoadCellSize: CGFloat = 24
static let statBoxMinWidth: CGFloat = 80
static let winIndicatorSize: CGFloat = 24
static let roadDotSize: CGFloat = 28
}
// MARK: - Preview
#Preview {
StatisticsSheetView(results: [
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6, playerPair: true),
RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5),
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7),
RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8, bankerPair: true),
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 6),
RoundResult(result: .tie, playerValue: 5, bankerValue: 5),
RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3),
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 8, playerPair: true, bankerPair: true)
])
StatisticsSheetView(state: GameState(settings: GameSettings()))
}

View File

@ -24,11 +24,6 @@ struct CompactHandView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
/// Scaled animation duration based on dealing speed.
private var animationDuration: Double {
Design.Animation.springDuration * dealingSpeed
}
// MARK: - Constants
/// Overlap ratio relative to card width (negative = overlap)
@ -114,7 +109,7 @@ struct CompactHandView: View {
}
.animation(
showAnimations
? .spring(duration: animationDuration, bounce: Design.Animation.springBounce)
? CasinoDesign.Animation.cardDeal(speed: dealingSpeed)
: .none,
value: cards.count
)

View File

@ -19,13 +19,17 @@ enum GamePhase: Equatable {
}
/// Main game state manager.
/// Conforms to CasinoGameState for shared game behaviors.
@Observable
@MainActor
final class GameState {
// MARK: - Core State
final class GameState: CasinoGameState {
// MARK: - CasinoGameState Conformance
typealias Stats = BlackjackStats
typealias GameSettingsType = GameSettings
/// Current player balance.
private(set) var balance: Int
var balance: Int
/// Current game phase.
private(set) var currentPhase: GamePhase = .betting
@ -108,16 +112,28 @@ final class GameState {
/// The result of the last round.
private(set) var lastRoundResult: RoundResult?
/// Round history for statistics.
/// Round history for current session statistics.
private(set) var roundHistory: [RoundResult] = []
// MARK: - Statistics (persisted)
// MARK: - Session Tracking (SessionManagedGame)
private(set) var totalWinnings: Int = 0
private(set) var biggestWin: Int = 0
private(set) var biggestLoss: Int = 0
private(set) var blackjackCount: Int = 0
private(set) var bustCount: Int = 0
/// The currently active session.
var currentSession: BlackjackSession?
/// History of completed sessions.
var sessionHistory: [BlackjackSession] = []
/// Starting balance for new sessions (from settings).
var startingBalance: Int { settings.startingBalance }
/// Current game style identifier.
var currentGameStyle: String { settings.gameStyle.rawValue }
/// The bet amount for the current round (tracked for stats).
private var roundBetAmount: Int = 0
/// Whether a session end has been requested (shows confirmation).
var showEndSessionConfirmation: Bool = false
// MARK: - Persistence
@ -161,7 +177,6 @@ final class GameState {
/// 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:
@ -188,16 +203,12 @@ final class GameState {
return false
}
/// The minimum bet level across all active bet types.
/// The minimum bet level across all 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
}
min(currentBet, perfectPairsBet, twentyOnePlusThreeBet)
}
/// Whether the current hand can hit.
@ -333,9 +344,13 @@ final class GameState {
self.balance = settings.startingBalance
self.engine = BlackjackEngine(settings: settings)
self.onboarding = OnboardingState(gameIdentifier: "blackjack")
self.onboarding.registerHintKeys("bettingZone", "dealButton", "playerActions")
self.persistence = CloudSyncManager<BlackjackGameData>()
syncSoundSettings()
loadSavedGame()
// Ensure we have an active session
ensureActiveSession()
}
/// Syncs sound settings with SoundManager.
@ -356,58 +371,56 @@ final class GameState {
private func loadSavedGame() {
let data = persistence.load()
self.balance = data.balance
self.totalWinnings = data.totalWinnings
self.biggestWin = data.biggestWin
self.biggestLoss = data.biggestLoss
self.blackjackCount = data.blackjackCount
self.bustCount = data.bustCount
self.currentSession = data.currentSession
self.sessionHistory = data.sessionHistory
Design.debugLog("📂 Loaded game data:")
Design.debugLog(" - balance: \(data.balance)")
Design.debugLog(" - currentSession: \(data.currentSession?.id.uuidString ?? "none")")
Design.debugLog(" - sessionHistory count: \(data.sessionHistory.count)")
if let session = data.currentSession {
Design.debugLog(" - current session rounds: \(session.roundsPlayed), duration: \(session.duration)s")
}
// Set up callback for when iCloud data arrives later
persistence.onCloudDataReceived = { [weak self] newData in
guard let self else { return }
self.balance = newData.balance
self.totalWinnings = newData.totalWinnings
self.biggestWin = newData.biggestWin
self.biggestLoss = newData.biggestLoss
self.blackjackCount = newData.blackjackCount
self.bustCount = newData.bustCount
self.currentSession = newData.currentSession
self.sessionHistory = newData.sessionHistory
}
}
/// Saves current game data to iCloud and local storage.
private func saveGameData() {
let savedRounds: [SavedRoundResult] = roundHistory.map { result in
SavedRoundResult(
date: Date(),
mainResult: result.mainHandResult.saveName,
hadSplit: result.hadSplit,
totalWinnings: result.totalWinnings
)
func saveGameData() {
// Update current session before saving
if var session = currentSession {
session.endingBalance = balance
// Keep session's game style in sync with current settings
session.gameStyle = settings.gameStyle.rawValue
currentSession = session
}
let data = BlackjackGameData(
lastModified: Date(),
balance: balance,
roundHistory: savedRounds,
totalWinnings: totalWinnings,
biggestWin: biggestWin,
biggestLoss: biggestLoss,
blackjackCount: blackjackCount,
bustCount: bustCount
currentSession: currentSession,
sessionHistory: sessionHistory
)
persistence.save(data)
Design.debugLog("💾 Saved game data - session rounds: \(currentSession?.roundsPlayed ?? 0)")
}
/// Clears all saved data.
/// Clears all saved data and starts fresh.
func clearAllData() {
persistence.reset()
balance = settings.startingBalance
totalWinnings = 0
biggestWin = 0
biggestLoss = 0
blackjackCount = 0
bustCount = 0
currentSession = nil
sessionHistory = []
roundHistory = []
roundBetAmount = 0
startNewSession()
newRound()
}
@ -467,6 +480,10 @@ final class GameState {
func deal() async {
guard canDeal else { return }
// Track bet amount for statistics
roundBetAmount = currentBet + perfectPairsBet + twentyOnePlusThreeBet
Design.debugLog("🎴 Deal started - roundBetAmount: \(roundBetAmount)")
// Ensure enough cards for a full hand - reshuffle if needed
if !engine.canDealNewHand {
engine.reshuffle()
@ -497,7 +514,18 @@ final class GameState {
playerHandsVisibleCardCount = [0]
dealerVisibleCardCount = 0
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
// Animation timing
let animationDuration = Design.Animation.springDuration * settings.dealingSpeed
// Brief delay to let SwiftUI render the player hands container before cards fly in
// This ensures the container with placeholders is visible before the first card animates
if settings.showAnimations {
try? await Task.sleep(for: .milliseconds(200))
}
// Small delay for card to appear on screen before updating badge (~15% of animation)
let cardAppearDelay = settings.showAnimations ? animationDuration * 0.15 : 0
// Remaining delay after badge update to complete the animation
let remainingDelay = settings.showAnimations ? animationDuration * 0.85 : 0
// European no-hole-card: deal 3 cards (player, dealer, player)
// American style: deal 4 cards (player, dealer, player, dealer)
@ -511,16 +539,23 @@ final class GameState {
dealerHand.cards.append(card)
}
sound.play(.cardDeal)
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
// Wait for card to appear on screen
if cardAppearDelay > 0 {
try? await Task.sleep(for: .seconds(cardAppearDelay))
}
// Mark card as visible after animation delay
// Now mark card as visible (badge updates)
if i % 2 == 0 {
playerHandsVisibleCardCount[0] += 1
} else {
dealerVisibleCardCount += 1
}
// Wait for remaining animation before dealing next card
if remainingDelay > 0 {
try? await Task.sleep(for: .seconds(remainingDelay))
}
}
}
@ -639,15 +674,24 @@ final class GameState {
playerHands[activeHandIndex].cards.append(card)
sound.play(.cardDeal)
// Wait for animation if enabled
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
// Animation timing
let animationDuration = Design.Animation.springDuration * settings.dealingSpeed
let cardAppearDelay = settings.showAnimations ? animationDuration * 0.15 : 0
let remainingDelay = settings.showAnimations ? animationDuration * 0.85 : 0
// Wait for card to appear on screen
if cardAppearDelay > 0 {
try? await Task.sleep(for: .seconds(cardAppearDelay))
}
// Mark card as visible after animation
// Mark card as visible (badge updates)
playerHandsVisibleCardCount[activeHandIndex] += 1
// Wait for remaining animation before processing result
if remainingDelay > 0 {
try? await Task.sleep(for: .seconds(remainingDelay))
}
// Check for bust or 21
if playerHands[activeHandIndex].isBusted {
playerHands[activeHandIndex].result = .bust
@ -696,14 +740,23 @@ final class GameState {
playerHands[activeHandIndex].cards.append(card)
sound.play(.cardDeal)
// Wait for animation if enabled
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
// Animation timing
let animationDuration = Design.Animation.springDuration * settings.dealingSpeed
let cardAppearDelay = settings.showAnimations ? animationDuration * 0.15 : 0
let remainingDelay = settings.showAnimations ? animationDuration * 0.85 : 0
// Wait for card to appear on screen
if cardAppearDelay > 0 {
try? await Task.sleep(for: .seconds(cardAppearDelay))
}
// Mark card as visible after animation
// Mark card as visible (badge updates)
playerHandsVisibleCardCount[activeHandIndex] += 1
// Wait for remaining animation
if remainingDelay > 0 {
try? await Task.sleep(for: .seconds(remainingDelay))
}
}
if playerHands[activeHandIndex].isBusted {
@ -741,34 +794,53 @@ final class GameState {
balance -= originalHand.bet
sound.play(.chipPlace)
// Deal one card to each hand
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
if let card1 = engine.dealCard() {
hand1.cards.append(card1)
sound.play(.cardDeal)
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
}
if let card2 = engine.dealCard() {
hand2.cards.append(card2)
sound.play(.cardDeal)
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
}
// Replace original with split hands
// Replace original with split hands first (so visible counts are tracked correctly)
playerHands.remove(at: activeHandIndex)
playerHands.insert(hand1, at: activeHandIndex)
playerHands.insert(hand2, at: activeHandIndex + 1)
// Update visible card counts - each split hand starts with 2 visible cards
// Each split hand starts with 1 visible card (the original cards)
playerHandsVisibleCardCount.remove(at: activeHandIndex)
playerHandsVisibleCardCount.insert(2, at: activeHandIndex)
playerHandsVisibleCardCount.insert(2, at: activeHandIndex + 1)
playerHandsVisibleCardCount.insert(1, at: activeHandIndex)
playerHandsVisibleCardCount.insert(1, at: activeHandIndex + 1)
// Animation timing
let animationDuration = Design.Animation.springDuration * settings.dealingSpeed
let cardAppearDelay = settings.showAnimations ? animationDuration * 0.15 : 0
let remainingDelay = settings.showAnimations ? animationDuration * 0.85 : 0
// Brief delay to let SwiftUI render the split hands before dealing second cards
// This ensures both hand containers are visible before cards animate in
if settings.showAnimations {
try? await Task.sleep(for: .milliseconds(150))
}
// Deal one card to each hand (with full animation timing for each)
if let card1 = engine.dealCard() {
playerHands[activeHandIndex].cards.append(card1)
sound.play(.cardDeal)
if cardAppearDelay > 0 {
try? await Task.sleep(for: .seconds(cardAppearDelay))
}
playerHandsVisibleCardCount[activeHandIndex] += 1
if remainingDelay > 0 {
try? await Task.sleep(for: .seconds(remainingDelay))
}
}
if let card2 = engine.dealCard() {
playerHands[activeHandIndex + 1].cards.append(card2)
sound.play(.cardDeal)
if cardAppearDelay > 0 {
try? await Task.sleep(for: .seconds(cardAppearDelay))
}
playerHandsVisibleCardCount[activeHandIndex + 1] += 1
if remainingDelay > 0 {
try? await Task.sleep(for: .seconds(remainingDelay))
}
}
// If split aces, typically only one card each and stand
if originalHand.cards[0].rank == .ace && !settings.resplitAces {
@ -827,20 +899,24 @@ final class GameState {
private func dealerTurn() async {
currentPhase = .dealerTurn
let delay = settings.showAnimations ? 0.5 * settings.dealingSpeed : 0
// Animation timing
let animationDuration = Design.Animation.springDuration * settings.dealingSpeed
let delay = settings.showAnimations ? animationDuration : 0
// For flip animation, card face becomes visible halfway through (at 90° rotation)
let flipMidpointDelay = settings.showAnimations ? animationDuration / 2.0 : 0
// European no-hole-card: deal the second card now
if settings.noHoleCard && dealerHand.cards.count == 1 {
if let card = engine.dealCard() {
dealerHand.cards.append(card)
// Mark card as visible immediately - face is visible as soon as card appears
dealerVisibleCardCount += 1
sound.play(.cardDeal)
// Wait for animation to complete before checking blackjack
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
// Mark card as visible after animation
dealerVisibleCardCount += 1
}
// Check for dealer blackjack in European mode
@ -860,31 +936,38 @@ final class GameState {
return
}
} else {
// American style: reveal hole card (card is already in hand, just mark as visible)
// American style: reveal hole card (card is already in hand)
// The flip animation shows the card face at the midpoint (90° rotation)
sound.play(.cardFlip)
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
// Wait until card face becomes visible (halfway through flip)
if flipMidpointDelay > 0 {
try? await Task.sleep(for: .seconds(flipMidpointDelay))
}
// Mark hole card as visible (if not already)
// Mark hole card as visible now that card face is showing
if dealerVisibleCardCount < dealerHand.cards.count {
dealerVisibleCardCount = dealerHand.cards.count
}
// Wait for remaining flip animation to complete before drawing more cards
if flipMidpointDelay > 0 {
try? await Task.sleep(for: .seconds(flipMidpointDelay))
}
}
// Dealer draws
while engine.dealerShouldHit(hand: dealerHand) {
if let card = engine.dealCard() {
dealerHand.cards.append(card)
// Mark card as visible immediately - face is visible as soon as card appears
dealerVisibleCardCount += 1
sound.play(.cardDeal)
// Wait for animation to complete before drawing next card
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
// Mark card as visible after animation
dealerVisibleCardCount += 1
}
}
@ -895,8 +978,6 @@ final class GameState {
/// 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 }
@ -1022,21 +1103,53 @@ final class GameState {
roundWinnings -= sideBetsTotal
}
// Update statistics
totalWinnings += roundWinnings
if roundWinnings > biggestWin {
biggestWin = roundWinnings
// Determine round outcome for session stats
let mainResult = playerHands.first?.result
let isWin = mainResult?.isWin ?? false
let isLoss = mainResult == .lose || mainResult == .bust
let isPush = mainResult == .push
let isSurrender = mainResult == .surrender
let hadDoubled = playerHands.contains { $0.isDoubledDown }
let hadSplitHands = playerHands.count > 1
// Determine the round outcome enum
let outcome: RoundOutcome
if isWin {
outcome = .win
} else if isPush || isSurrender {
outcome = .push // Surrender and push treated as push for session stats
} else {
outcome = .lose
}
if roundWinnings < biggestLoss {
biggestLoss = roundWinnings
// Capture values for closure
let tookInsurance = insuranceBet > 0
let wonInsurance = insResult == .insuranceWin
// Record round in session using CasinoKit protocol
recordSessionRound(
winnings: roundWinnings,
betAmount: roundBetAmount,
outcome: outcome
) { stats in
// Update Blackjack-specific stats
if wasBlackjack { stats.blackjacks += 1 }
if hadBust { stats.busts += 1 }
if isSurrender { stats.surrenders += 1 }
if hadDoubled { stats.doubles += 1 }
if hadSplitHands { stats.splits += 1 }
if tookInsurance {
stats.insuranceTaken += 1
if wonInsurance {
stats.insuranceWon += 1
}
if wasBlackjack {
blackjackCount += 1
}
if hadBust {
bustCount += 1
}
Design.debugLog("📊 Session stats update:")
Design.debugLog(" - roundsPlayed: \(currentSession?.roundsPlayed ?? 0)")
Design.debugLog(" - duration: \(currentSession?.duration ?? 0) seconds")
// Create round result with all hand results, per-hand winnings, and side bets
let allHandResults = playerHands.map { $0.result ?? .lose }
@ -1139,15 +1252,166 @@ final class GameState {
sound.play(.newRound)
}
// MARK: - Game Reset
// MARK: - SessionManagedGame Implementation
/// Resets the entire game (keeps statistics).
func resetGame() {
balance = settings.startingBalance
/// Resets game-specific state when starting a new session.
func resetForNewSession() {
roundHistory = []
engine.reshuffle()
newRound()
saveGameData()
}
/// Aggregated Blackjack-specific stats from all sessions.
var aggregatedBlackjackStats: BlackjackStats {
allSessions.aggregatedBlackjackStats()
}
// MARK: - Game Reset
/// Resets the entire game (keeps statistics).
/// Uses CasinoKit's performResetGame() which properly handles session ending.
func resetGame() {
performResetGame()
// Note: newRound() is called by resetForNewSession()
}
// MARK: - Debug Helpers
//
// DEBUG TESTING UTILITIES
//
//
// These methods are only available in DEBUG builds and provide ways to test
// specific game scenarios that are difficult to trigger with random card deals.
//
// ACCESS:
// - Settings sheet scroll to bottom "DEBUG" section (orange)
// - Only visible in DEBUG builds (not in Release/App Store builds)
//
// ADDING NEW DEBUG SCENARIOS:
// 1. Add a new async function below following the pattern of `debugDealWithPair()`
// 2. Add a corresponding button in SettingsView.swift inside the #if DEBUG block
// 3. Use `triggerDebugDeal(state:)` pattern to dismiss sheet before executing
//
// ANIMATION TIMING NOTES:
// - Always add 100ms delay after phase changes to let SwiftUI render containers
// - Use `cardAppearDelay` (15% of animation) before updating visible counts
// - Use `remainingDelay` (85% of animation) before dealing next card
// - For splits, add 150ms delay after creating hands before dealing second cards
//
//
#if DEBUG
/// Forces a deal with a splittable pair for testing split hand scrolling and animations.
///
/// This debug function deals a pair of 8s to the player (a classic split scenario)
/// with a dealer showing 6 (favorable split situation). Use this to test:
/// - Split hand scrolling behavior
/// - Card dealing animations for split hands
/// - PlayerHandsContainer centering with multiple hands
///
/// ## Usage
/// Triggered via Settings DEBUG "Deal Splittable Pair (8s)"
///
/// ## Dealt Cards
/// - Player: 8, 8 (pair, can split)
/// - Dealer: 6 (up), 10 (hole)
///
/// ## Notes
/// - Auto-places minimum bet if none exists
/// - Must be in betting phase to work
/// - Includes proper animation timing delays
func debugDealWithPair() async {
Design.debugLog("🧪 Debug deal started - phase: \(currentPhase), bet: \(currentBet)")
// Auto-place minimum bet if none exists
if currentBet < settings.minBet {
currentBet = settings.minBet
Design.debugLog("🧪 Auto-placed min bet: \(currentBet)")
}
guard currentPhase == .betting else {
Design.debugLog("🧪 Debug deal failed - not in betting phase (phase: \(currentPhase))")
return
}
Design.debugLog("🧪 Starting debug deal with pair of 8s")
currentPhase = .dealing
dealerHand = BlackjackHand()
activeHandIndex = 0
insuranceBet = 0
// Reset visible card counts
playerHandsVisibleCardCount = [0]
dealerVisibleCardCount = 0
// Brief delay to let PlayerHandsContainer appear before cards fly in
// (fixes race condition where first card animation is missed)
if settings.showAnimations {
try? await Task.sleep(for: .milliseconds(100))
}
// Create a pair of 8s (classic split scenario) with dealer showing 6
let card1 = Card(suit: .hearts, rank: .eight)
let card2 = Card(suit: .spades, rank: .eight)
let dealerCard1 = Card(suit: .diamonds, rank: .six)
let dealerCard2 = Card(suit: .clubs, rank: .ten)
playerHands = [BlackjackHand(cards: [], bet: currentBet)]
// Animation timing (matches deal() function)
let animationDuration = Design.Animation.springDuration * settings.dealingSpeed
let cardAppearDelay = settings.showAnimations ? animationDuration * 0.15 : 0
let remainingDelay = settings.showAnimations ? animationDuration * 0.85 : 0
// Deal player card 1
playerHands[0].cards.append(card1)
sound.play(.cardDeal)
if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) }
playerHandsVisibleCardCount[0] += 1
if remainingDelay > 0 { try? await Task.sleep(for: .seconds(remainingDelay)) }
// Deal dealer card 1 (face up)
dealerHand.cards.append(dealerCard1)
sound.play(.cardDeal)
if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) }
dealerVisibleCardCount += 1
if remainingDelay > 0 { try? await Task.sleep(for: .seconds(remainingDelay)) }
// Deal player card 2 (matching rank for split)
playerHands[0].cards.append(card2)
sound.play(.cardDeal)
if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) }
playerHandsVisibleCardCount[0] += 1
if remainingDelay > 0 { try? await Task.sleep(for: .seconds(remainingDelay)) }
// Deal dealer hole card (face down)
dealerHand.cards.append(dealerCard2)
sound.play(.cardDeal)
if cardAppearDelay > 0 { try? await Task.sleep(for: .seconds(cardAppearDelay)) }
dealerVisibleCardCount += 1
if remainingDelay > 0 { try? await Task.sleep(for: .seconds(remainingDelay)) }
currentPhase = .playerTurn(handIndex: 0)
Design.debugLog("🧪 Debug deal complete - pair of 8s, can split: \(canSplit)")
}
//
// ADD NEW DEBUG SCENARIOS BELOW
//
//
// Example template for a new debug scenario:
//
// /// Description of what this tests.
// func debugDealWithBlackjack() async {
// Design.debugLog("🧪 Debug blackjack started")
// // ... implementation following the pattern above
// }
//
// Then add a button in SettingsView.swift's DEBUG section.
//
#endif
}

View File

@ -1,8 +1,8 @@
//
// Hand.swift
// BlackjackHand.swift
// Blackjack
//
// Represents a Blackjack hand with value calculation.
// Model representing a Blackjack hand with value calculation.
//
import Foundation
@ -68,6 +68,14 @@ struct BlackjackHand: Identifiable, Equatable {
/// Calculates both hard and soft values.
private func calculateValues() -> (hard: Int, soft: Int) {
Self.calculateValues(for: cards)
}
// MARK: - Static Helpers for Card Value Calculation
/// Calculates hard and soft values for any array of cards.
/// Use this for calculating values of partial hands (e.g., during animations).
static func calculateValues(for cards: [Card]) -> (hard: Int, soft: Int) {
var hardValue = 0
var aceCount = 0
@ -98,6 +106,18 @@ struct BlackjackHand: Identifiable, Equatable {
return (hardValue, softValue)
}
/// Returns the best value (highest without busting) for any array of cards.
static func bestValue(for cards: [Card]) -> Int {
let (hard, soft) = calculateValues(for: cards)
return soft <= 21 ? soft : hard
}
/// Returns whether the cards have a usable soft ace.
static func hasSoftAce(for cards: [Card]) -> Bool {
let (hard, soft) = calculateValues(for: cards)
return soft <= 21 && soft != hard
}
/// Display string for the hand value.
var valueDisplay: String {
if isBlackjack {

View File

@ -0,0 +1,107 @@
//
// BlackjackStats.swift
// Blackjack
//
// Blackjack-specific statistics that conform to CasinoKit's GameSpecificStats.
//
import Foundation
import SwiftUI
import CasinoKit
/// Blackjack-specific session statistics.
/// Tracks blackjacks, busts, surrenders, doubles, and splits.
struct BlackjackStats: GameSpecificStats {
/// Number of blackjacks hit.
var blackjacks: Int = 0
/// Number of busted hands.
var busts: Int = 0
/// Number of surrendered hands.
var surrenders: Int = 0
/// Number of doubled down hands.
var doubles: Int = 0
/// Number of split hands.
var splits: Int = 0
/// Number of insurance bets taken.
var insuranceTaken: Int = 0
/// Number of insurance bets won.
var insuranceWon: Int = 0
// MARK: - GameSpecificStats
init() {}
/// Display items for the statistics UI.
var displayItems: [StatDisplayItem] {
[
StatDisplayItem(
icon: "21.circle.fill",
iconColor: .yellow,
label: String(localized: "Blackjacks"),
value: "\(blackjacks)",
valueColor: .yellow
),
StatDisplayItem(
icon: "flame.fill",
iconColor: .orange,
label: String(localized: "Busts"),
value: "\(busts)",
valueColor: .orange
),
StatDisplayItem(
icon: "arrow.up.right.circle.fill",
iconColor: .purple,
label: String(localized: "Doubles"),
value: "\(doubles)",
valueColor: .purple
),
StatDisplayItem(
icon: "arrow.triangle.branch",
iconColor: .blue,
label: String(localized: "Splits"),
value: "\(splits)",
valueColor: .blue
),
StatDisplayItem(
icon: "flag.fill",
iconColor: .gray,
label: String(localized: "Surrenders"),
value: "\(surrenders)",
valueColor: .gray
)
]
}
}
// MARK: - Aggregation Extension
extension Array where Element == GameSession<BlackjackStats> {
/// Aggregates Blackjack-specific stats from all sessions.
func aggregatedBlackjackStats() -> BlackjackStats {
var combined = BlackjackStats()
for session in self {
combined.blackjacks += session.gameStats.blackjacks
combined.busts += session.gameStats.busts
combined.surrenders += session.gameStats.surrenders
combined.doubles += session.gameStats.doubles
combined.splits += session.gameStats.splits
combined.insuranceTaken += session.gameStats.insuranceTaken
combined.insuranceWon += session.gameStats.insuranceWon
}
return combined
}
}
// MARK: - Type Aliases for Convenience
/// Blackjack session type alias.
typealias BlackjackSession = GameSession<BlackjackStats>

View File

@ -69,9 +69,10 @@ enum DeckCount: Int, CaseIterable, Identifiable {
}
/// Observable settings class for Blackjack configuration.
/// Conforms to GameSettingsProtocol for shared settings behavior.
@Observable
@MainActor
final class GameSettings {
final class GameSettings: GameSettingsProtocol {
// MARK: - Game Style
/// The preset rule variation.
@ -132,13 +133,8 @@ final class GameSettings {
/// Whether to show dealing animations.
var showAnimations: Bool = true { didSet { save() } }
/// Speed of card dealing (1.0 = normal)
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() } }
/// Speed of card dealing (uses CasinoDesign.DealingSpeed constants)
var dealingSpeed: Double = CasinoDesign.DealingSpeed.normal { didSet { save() } }
// MARK: - Display Settings
@ -241,7 +237,6 @@ final class GameSettings {
self.blackjackPayout = data.blackjackPayout
self.insuranceAllowed = data.insuranceAllowed
self.neverAskInsurance = data.neverAskInsurance
self.sideBetsEnabled = data.sideBetsEnabled
self.showAnimations = data.showAnimations
self.dealingSpeed = data.dealingSpeed
self.showCardsRemaining = data.showCardsRemaining
@ -268,7 +263,6 @@ final class GameSettings {
blackjackPayout: blackjackPayout,
insuranceAllowed: insuranceAllowed,
neverAskInsurance: neverAskInsurance,
sideBetsEnabled: sideBetsEnabled,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
showCardsRemaining: showCardsRemaining,
@ -296,9 +290,8 @@ final class GameSettings {
blackjackPayout = 1.5
insuranceAllowed = true
neverAskInsurance = false
sideBetsEnabled = false
showAnimations = true
dealingSpeed = 1.0
dealingSpeed = CasinoDesign.DealingSpeed.normal
showCardsRemaining = true
showHistory = true
showHints = true

View File

@ -874,6 +874,29 @@
}
}
},
"ALL TIME SUMMARY" : {
"comment" : "Title for a section in the statistics sheet that provides a summary of the user's overall performance over all time.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "ALL TIME SUMMARY"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "RESUMEN HISTÓRICO"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "RÉSUMÉ GLOBAL"
}
}
}
},
"Allow doubling on split hands" : {
"localizations" : {
"en" : {
@ -1105,6 +1128,7 @@
}
},
"Balance" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -1126,6 +1150,29 @@
}
}
},
"BALANCE" : {
"comment" : "Title of a section in the session detail view that shows the user's starting and ending balances.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "BALANCE"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "SALDO"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "SOLDE"
}
}
}
},
"Basic Strategy" : {
"localizations" : {
"en" : {
@ -1171,7 +1218,27 @@
}
},
"Beat the Dealer" : {
"comment" : "Welcome screen feature title for game objective.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Beat the Dealer"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Vence al Crupier"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Battez le Croupier"
}
}
}
},
"Beat the dealer by getting a hand value closer to 21 without going over." : {
"comment" : "Text for the objective of the game.",
@ -1198,6 +1265,7 @@
}
},
"Best" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -1483,6 +1551,7 @@
}
},
"BIGGEST SWINGS" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -1663,7 +1732,27 @@
}
},
"Built-in hints show optimal plays based on basic strategy" : {
"comment" : "Welcome screen feature description for strategy hints.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Built-in hints show optimal plays based on basic strategy"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Las sugerencias integradas muestran las jugadas óptimas basadas en estrategia básica"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Les conseils intégrés montrent les jeux optimaux basés sur la stratégie de base"
}
}
}
},
"BUST" : {
"localizations" : {
@ -1863,8 +1952,28 @@
}
}
},
"Change table limits, rules, and side bets in settings" : {
"CHIPS STATS" : {
"comment" : "Title of a section in the Statistics Sheet that shows statistics related to the user's chips.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "CHIPS STATS"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "ESTADÍSTICAS DE FICHAS"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "STATISTIQUES DES JETONS"
}
}
}
},
"Chips, cards, and results" : {
"localizations" : {
@ -2210,7 +2319,27 @@
}
},
"Customize Rules" : {
"comment" : "Welcome screen feature title for customizing game rules.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Customize Rules"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Personaliza las Reglas"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Personnalisez les Règles"
}
}
}
},
"DATA" : {
"localizations" : {
@ -2256,6 +2385,9 @@
}
}
}
},
"Deal Splittable Pair (8s)" : {
},
"DEALER" : {
"localizations" : {
@ -2620,6 +2752,32 @@
}
}
}
},
"Delete" : {
"comment" : "A button label that deletes a session.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Delete"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Eliminar"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Supprimer"
}
}
}
},
"Delete Session?" : {
},
"DISPLAY" : {
"localizations" : {
@ -3024,6 +3182,29 @@
}
}
},
"Doubles" : {
"comment" : "Label for a stat item in the statistics UI that shows the number of times a hand was doubled down.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Doubles"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Doblados"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Doublés"
}
}
}
},
"Enable 'Card Count' in Settings to practice." : {
"localizations" : {
"en" : {
@ -3069,6 +3250,7 @@
}
},
"Enable Side Bets" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -3090,6 +3272,52 @@
}
}
},
"End Session" : {
"comment" : "The text for a button that ends the current game session.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "End Session"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Terminar Sesión"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Terminer la Session"
}
}
}
},
"End Session?" : {
"comment" : "A confirmation dialog title that asks if the user wants to end their current session.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "End Session?"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "¿Terminar Sesión?"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Terminer la Session?"
}
}
}
},
"European" : {
"localizations" : {
"en" : {
@ -3290,6 +3518,29 @@
}
}
},
"GAME STATS" : {
"comment" : "Title for a section in the statistics sheet dedicated to blackjack-specific statistics.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "GAME STATS"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "ESTADÍSTICAS DEL JUEGO"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "STATISTIQUES DE JEU"
}
}
}
},
"GAME STYLE" : {
"localizations" : {
"en" : {
@ -3409,7 +3660,27 @@
}
},
"Get closer to 21 than the dealer without going over" : {
"comment" : "Welcome screen feature description for game objective.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Get closer to 21 than the dealer without going over"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Acércate más a 21 que el crupier sin pasarte"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Approchez-vous de 21 plus que le croupier sans dépasser"
}
}
}
},
"H17 rule, increases house edge" : {
"localizations" : {
@ -3455,6 +3726,52 @@
}
}
},
"Hands" : {
"comment" : "Label for the number of blackjack hands played in a session.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hands"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Manos"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mains"
}
}
}
},
"Hands played" : {
"comment" : "A label describing the number of hands a player has played in a game.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hands played"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Manos jugadas"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mains jouées"
}
}
}
},
"Haptic Feedback" : {
"localizations" : {
"en" : {
@ -3846,6 +4163,29 @@
}
}
},
"IN GAME STATS" : {
"comment" : "Title of a section in the Statistics Sheet that shows in-game statistics.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "IN GAME STATS"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "ESTADÍSTICAS EN JUEGO"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "STATISTIQUES EN JEU"
}
}
}
},
"Increase bets when the count is positive." : {
"localizations" : {
"en" : {
@ -4117,7 +4457,27 @@
}
},
"Learn Strategy" : {
"comment" : "Welcome screen feature title for strategy hints.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Learn Strategy"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aprende Estrategia"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Apprenez la Stratégie"
}
}
}
},
"LEGAL" : {
"localizations" : {
@ -4164,6 +4524,7 @@
}
},
"Losses" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -4185,6 +4546,29 @@
}
}
},
"Lost" : {
"comment" : "Label for a game outcome circle indicating a loss.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Lost"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Perdido"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Perdu"
}
}
}
},
"Lower house edge" : {
"comment" : "Description of a deck count option when the user selects 2 decks.",
"isCommentAutoGenerated" : true,
@ -4826,6 +5210,7 @@
}
},
"OUTCOMES" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -4964,6 +5349,7 @@
}
},
"Perfect Pairs (25:1) and 21+3 (100:1)" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -5030,6 +5416,29 @@
}
}
},
"PLAYER" : {
"comment" : "Title to display for a player hand when the hand number is not available.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "PLAYER"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "JUGADOR"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "JOUEUR"
}
}
}
},
"Player hand: %@. Value: %@" : {
"comment" : "A user-readable string describing a player's blackjack hand, including the card values and any relevant game results. The argument is a comma-separated list of the card descriptions in the player's hand.",
"isCommentAutoGenerated" : true,
@ -5121,9 +5530,6 @@
}
}
}
},
"Practice Free" : {
},
"Privacy Policy" : {
"localizations" : {
@ -5147,6 +5553,29 @@
}
}
},
"Push" : {
"comment" : "Label for the \"Push\" outcome in the game stats section of the statistics sheet.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Push"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Empate"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Égalité"
}
}
}
},
"PUSH" : {
"localizations" : {
"en" : {
@ -5194,6 +5623,7 @@
}
},
"Pushes" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -5399,6 +5829,7 @@
}
},
"Rounds" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -5554,10 +5985,77 @@
}
}
},
"See detailed stats for each play session, just like at a real casino" : {
"comment" : "Description of a feature in the welcome sheet that allows users to track their gaming sessions.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "See detailed stats for each play session, just like at a real casino"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ve estadísticas detalladas de cada sesión de juego, como en un casino real"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Consultez les statistiques détaillées de chaque session de jeu, comme dans un vrai casino"
}
}
}
},
"Select a chip and tap the bet area" : {
"comment" : "Onboarding hint for placing bets.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Select a chip and tap the bet area"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Selecciona una ficha y toca el área de apuesta"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sélectionnez un jeton et touchez la zone de mise"
}
}
}
},
"SESSION PERFORMANCE" : {
"comment" : "Title of a section in the statistics sheet that shows performance metrics for individual sessions.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "SESSION PERFORMANCE"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "RENDIMIENTO DE SESIÓN"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "PERFORMANCE DE SESSION"
}
}
}
},
"SESSION SUMMARY" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -5579,6 +6077,29 @@
}
}
},
"Sessions" : {
"comment" : "Label for the number of blackjack game sessions.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sessions"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sesiones"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sessions"
}
}
}
},
"Settings" : {
"localizations" : {
"en" : {
@ -5741,7 +6262,26 @@
},
"Show Hint" : {
"comment" : "Label for a toolbar button that shows a hint.",
"isCommentAutoGenerated" : true
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Show Hint"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mostrar Sugerencia"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Afficher le Conseil"
}
}
}
},
"Show Hints" : {
"localizations" : {
@ -5791,6 +6331,7 @@
}
},
"SIDE BETS" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -6061,6 +6602,29 @@
}
}
},
"Splits" : {
"comment" : "Label for the number of split hands in the statistics UI.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Splits"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Divisiones"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Séparations"
}
}
}
},
"Stand" : {
"localizations" : {
"en" : {
@ -6197,9 +6761,6 @@
}
}
}
},
"Start with $1,000 and play risk-free" : {
},
"STARTING BALANCE" : {
"localizations" : {
@ -6677,6 +7238,9 @@
}
}
}
},
"This will permanently remove this session from your history." : {
},
"Three of a Kind" : {
"comment" : "Description of a 21+3 side bet outcome when they have three of a kind.",
@ -6724,6 +7288,52 @@
}
}
},
"Time" : {
"comment" : "Label for the duration of a blackjack game.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Time"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tiempo"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Temps"
}
}
}
},
"Total game time" : {
"comment" : "Label for a stat row displaying the total game time.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Total game time"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tiempo total de juego"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Temps de jeu total"
}
}
}
},
"Total Winnings" : {
"localizations" : {
"en" : {
@ -6746,6 +7356,29 @@
}
}
},
"Track Sessions" : {
"comment" : "Feature description in the welcome sheet for tracking detailed stats for each play session.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Track Sessions"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Seguimiento de Sesiones"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Suivez vos Sessions"
}
}
}
},
"Traditional European casino style." : {
"localizations" : {
"en" : {
@ -6904,6 +7537,29 @@
}
}
},
"Vegas Strip, Atlantic City, European, or create your own" : {
"comment" : "Feature description in the welcome sheet about customizing the game rules.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Vegas Strip, Atlantic City, European, or create your own"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Vegas Strip, Atlantic City, Europeo o crea el tuyo"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Vegas Strip, Atlantic City, Européen ou créez le vôtre"
}
}
}
},
"Vibration on actions" : {
"localizations" : {
"en" : {
@ -6995,6 +7651,7 @@
}
},
"Wins" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -7016,7 +7673,31 @@
}
}
},
"Won" : {
"comment" : "Label for a game outcome circle that indicates a win.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Won"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ganado"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Gagné"
}
}
}
},
"Worst" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -7060,6 +7741,29 @@
}
}
},
"You played %lld hands with a net result of %@. This session will be saved to your history." : {
"comment" : "A message that appears when a user ends a game session. It includes the number of hands played and the net result of the session.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "You played %1$lld hands with a net result of %2$@. This session will be saved to your history."
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Jugaste %1$lld manos con un resultado neto de %2$@. Esta sesión se guardará en tu historial."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Vous avez joué %1$lld mains avec un résultat net de %2$@. Cette session sera sauvegardée dans votre historique."
}
}
}
},
"You've run out of chips!" : {
"extractionState" : "stale",
"localizations" : {

View File

@ -8,41 +8,36 @@
import Foundation
import CasinoKit
/// Saved round result for history.
struct SavedRoundResult: Codable, Equatable {
let date: Date
let mainResult: String // "blackjack", "win", "lose", "push", "bust", "surrender"
let hadSplit: Bool
let totalWinnings: Int
}
/// Persistent game data that syncs to iCloud.
struct BlackjackGameData: PersistableGameData {
struct BlackjackGameData: PersistableGameData, SessionPersistable {
static let gameIdentifier = "blackjack"
var roundsPlayed: Int { roundHistory.count }
var roundsPlayed: Int {
// Total rounds from all sessions
let historicalRounds = sessionHistory.reduce(0) { $0 + $1.roundsPlayed }
let currentRounds = currentSession?.roundsPlayed ?? 0
return historicalRounds + currentRounds
}
var lastModified: Date
static var empty: BlackjackGameData {
BlackjackGameData(
lastModified: Date(),
balance: 10_000,
roundHistory: [],
totalWinnings: 0,
biggestWin: 0,
biggestLoss: 0,
blackjackCount: 0,
bustCount: 0
balance: 1_000,
currentSession: nil,
sessionHistory: []
)
}
/// Current player balance.
var balance: Int
var roundHistory: [SavedRoundResult]
var totalWinnings: Int
var biggestWin: Int
var biggestLoss: Int
var blackjackCount: Int
var bustCount: Int
/// The currently active session (nil if no session started).
var currentSession: BlackjackSession?
/// History of completed sessions.
var sessionHistory: [BlackjackSession]
}
/// Persistent settings data that syncs to iCloud.
@ -58,7 +53,7 @@ struct BlackjackSettingsData: PersistableGameData {
gameStyle: "vegas",
deckCount: 6,
tableLimits: "low",
startingBalance: 10_000,
startingBalance: 1_000,
dealerHitsSoft17: false,
doubleAfterSplit: true,
resplitAces: false,
@ -67,7 +62,6 @@ struct BlackjackSettingsData: PersistableGameData {
blackjackPayout: 1.5,
insuranceAllowed: true,
neverAskInsurance: false,
sideBetsEnabled: false,
showAnimations: true,
dealingSpeed: 1.0,
showCardsRemaining: true,
@ -92,7 +86,6 @@ struct BlackjackSettingsData: PersistableGameData {
var blackjackPayout: Double
var insuranceAllowed: Bool
var neverAskInsurance: Bool
var sideBetsEnabled: Bool
var showAnimations: Bool
var dealingSpeed: Double
var showCardsRemaining: Bool
@ -103,4 +96,3 @@ struct BlackjackSettingsData: PersistableGameData {
var hapticsEnabled: Bool
var soundVolume: Float
}

View File

@ -0,0 +1,43 @@
//
// BrandingConfig+Blackjack.swift
// Blackjack
//
// Blackjack-specific branding configurations for AppIconView and LaunchScreenView.
//
import SwiftUI
import CasinoKit
extension AppIconConfig {
/// Blackjack game icon configuration.
static let blackjack = AppIconConfig(
title: "BLACKJACK",
subtitle: "21",
iconSymbol: "suit.club.fill",
primaryColor: Color(red: 0.05, green: 0.35, blue: 0.15),
secondaryColor: Color(red: 0.03, green: 0.2, blue: 0.1)
)
}
extension LaunchScreenConfig {
/// Blackjack game launch screen configuration.
static let blackjack = LaunchScreenConfig(
title: "BLACKJACK",
subtitle: "21",
tagline: "Beat the Dealer",
iconSymbols: ["suit.club.fill", "suit.diamond.fill"],
primaryColor: Color(red: 0.05, green: 0.35, blue: 0.15),
secondaryColor: Color(red: 0.03, green: 0.2, blue: 0.1)
)
}
// MARK: - For Development Preview Comparison
extension AppIconConfig {
/// Baccarat config for side-by-side comparison in dev previews.
static let baccarat = AppIconConfig(
title: "BACCARAT",
iconSymbol: "suit.spade.fill"
)
}

View File

@ -20,7 +20,7 @@ enum Design {
static let showDebugBorders = false
/// Set to true to show debug log statements
static let showDebugLogs = false
static let showDebugLogs = true
/// Debug logger - only prints when showDebugLogs is true
static func debugLog(_ message: String) {
@ -156,7 +156,8 @@ extension Color {
enum Hand {
static let player = Color(red: 0.2, green: 0.5, blue: 0.8)
static let dealer = Color(red: 0.8, green: 0.3, blue: 0.3)
static let active = Color.yellow
/// Bright emerald green - readable with white text, indicates "your turn"
static let active = Color(red: 0.0, green: 0.7, blue: 0.45)
static let inactive = Color.white.opacity(CasinoDesign.Opacity.medium)
}

View File

@ -70,8 +70,10 @@ struct GameTableView: View {
SettingsView(settings: settings, gameState: gameState)
}
.onChange(of: showSettings) { wasShowing, isShowing in
// When settings sheet dismisses, check if we should show welcome
// When settings sheet dismisses, sync session and check welcome
if wasShowing && !isShowing {
// Sync current session with any settings changes (e.g., game style)
state.saveGameData()
checkForWelcomeSheet()
}
}
@ -84,6 +86,7 @@ struct GameTableView: View {
.sheet(isPresented: $showWelcome) {
WelcomeSheet(
gameName: "Blackjack",
gameEmoji: "🃏",
features: [
WelcomeFeature(
icon: "target",
@ -96,31 +99,27 @@ struct GameTableView: View {
description: String(localized: "Built-in hints show optimal plays based on basic strategy")
),
WelcomeFeature(
icon: "dollarsign.circle",
title: String(localized: "Practice Free"),
description: String(localized: "Start with $1,000 and play risk-free")
icon: "clock.badge.checkmark.fill",
title: String(localized: "Track Sessions"),
description: String(localized: "See detailed stats for each play session, just like at a real casino")
),
WelcomeFeature(
icon: "gearshape.fill",
title: String(localized: "Customize Rules"),
description: String(localized: "Change table limits, rules, and side bets in settings")
description: String(localized: "Vegas Strip, Atlantic City, European, or create your own")
)
],
onStartTutorial: {
showWelcome = false
state.onboarding.completeWelcome()
checkOnboardingHints()
},
onStartPlaying: {
// Mark all hints as shown FIRST so they don't appear
state.onboarding.markHintShown("bettingZone")
state.onboarding.markHintShown("dealButton")
state.onboarding.markHintShown("playerActions")
state.onboarding.completeWelcome()
showWelcome = false
}
onboarding: state.onboarding,
onDismiss: { showWelcome = false },
onShowHints: checkOnboardingHints
)
}
.onChange(of: showWelcome) { wasShowing, isShowing in
// Handle swipe-down dismissal: treat as "Start Playing" (no tooltips)
if wasShowing && !isShowing && !state.onboarding.hasCompletedWelcome {
state.onboarding.skipOnboarding()
}
}
.onChange(of: state.currentBet) { _, newBet in
if newBet > 0, state.onboarding.shouldShowHint("dealButton") {
showDealHintWithDelay()

View File

@ -26,6 +26,36 @@ struct SettingsView: View {
/// Accent color for settings components
private let accent = Color.Sheet.accent
//
// DEBUG HELPERS
//
//
// These methods trigger debug scenarios from GameState.
// The pattern is: dismiss sheet wait for animation call debug function
//
// TO ADD A NEW DEBUG TRIGGER:
// 1. Add a new trigger function below following the pattern
// 2. Add a corresponding button in the DEBUG SheetSection in body
// 3. The debug function itself lives in GameState.swift
//
//
#if DEBUG
/// Triggers the debug deal with splittable pair after dismissing the sheet.
/// Must dismiss first because the deal needs the main game view visible.
private func triggerDebugDeal(state: GameState) {
dismiss()
Task { @MainActor in
// Wait for sheet dismiss animation to complete
try? await Task.sleep(for: .milliseconds(500))
await state.debugDealWithPair()
}
}
// Add new trigger functions here following the same pattern:
// private func triggerDebugBlackjack(state: GameState) { ... }
#endif
var body: some View {
SheetContainerView(
title: String(localized: "Settings"),
@ -81,16 +111,6 @@ struct SettingsView: View {
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
SheetSection(title: String(localized: "DECK SETTINGS"), icon: "rectangle.portrait.on.rectangle.portrait") {
DeckCountPicker(selection: $settings.deckCount)
@ -283,7 +303,7 @@ struct SettingsView: View {
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text("\(state.roundsPlayed)")
Text("\(state.aggregatedStats.totalRoundsPlayed)")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
@ -295,7 +315,7 @@ struct SettingsView: View {
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
let winnings = state.totalWinnings
let winnings = state.aggregatedStats.totalWinnings
Text(winnings >= 0 ? "+$\(winnings)" : "-$\(abs(winnings))")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(winnings >= 0 ? .green : .red)
@ -408,6 +428,38 @@ struct SettingsView: View {
.padding(.top, Design.Spacing.xSmall)
}
//
// DEBUG SECTION - Only visible in DEBUG builds
// Add new debug buttons here. Each button should call a
// trigger function that dismisses the sheet first, then
// calls the corresponding debug function in GameState.
//
#if DEBUG
if let state = gameState {
SheetSection(title: "DEBUG", icon: "ant.fill") {
// Split Testing - deals a pair of 8s
Button {
triggerDebugDeal(state: state)
} label: {
HStack {
Text("Deal Splittable Pair (8s)")
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.orange)
Spacer()
Image(systemName: "rectangle.split.2x1")
.font(.system(size: Design.BaseFontSize.large))
.foregroundStyle(.orange)
}
.frame(minHeight: CasinoDesign.Size.actionRowMinHeight)
}
// Add new debug buttons here:
// Divider().background(Color.orange.opacity(Design.Opacity.hint))
// Button { triggerDebugBlackjack(state: state) } label: { ... }
}
}
#endif
// 13. Version info
Text(appVersionString)
.font(.system(size: Design.BaseFontSize.small))

View File

@ -2,227 +2,384 @@
// StatisticsSheetView.swift
// Blackjack
//
// Game statistics and history.
// Game statistics with session history and per-style stats.
//
import SwiftUI
import CasinoKit
struct StatisticsSheetView: View {
let state: GameState
@Bindable var state: GameState
@Environment(\.dismiss) private var dismiss
// MARK: - Computed Stats
private var totalRounds: Int {
state.roundHistory.count
}
private var wins: Int {
state.roundHistory.filter { $0.mainHandResult.isWin }.count
}
private var losses: Int {
state.roundHistory.filter {
$0.mainHandResult == .lose || $0.mainHandResult == .bust
}.count
}
private var pushes: Int {
state.roundHistory.filter { $0.mainHandResult == .push }.count
}
private var blackjacks: Int {
state.roundHistory.filter { $0.mainHandResult == .blackjack }.count
}
private var busts: Int {
state.roundHistory.filter { $0.mainHandResult == .bust }.count
}
private var surrenders: Int {
state.roundHistory.filter { $0.mainHandResult == .surrender }.count
}
private var winRate: Double {
guard totalRounds > 0 else { return 0 }
return Double(wins) / Double(totalRounds) * 100
}
private var totalWinnings: Int {
state.roundHistory.reduce(0) { $0 + $1.totalWinnings }
}
private var biggestWin: Int {
state.roundHistory.map { $0.totalWinnings }.filter { $0 > 0 }.max() ?? 0
}
private var biggestLoss: Int {
state.roundHistory.map { $0.totalWinnings }.filter { $0 < 0 }.min() ?? 0
}
@State private var selectedTab: StatisticsTab = .current
@State private var selectedSession: BlackjackSession?
var body: some View {
SheetContainerView(
title: String(localized: "Statistics"),
content: {
// Session Summary
SheetSection(title: String(localized: "SESSION SUMMARY"), icon: "chart.bar.fill") {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: Design.Spacing.medium) {
StatBox(title: String(localized: "Rounds"), value: "\(totalRounds)", color: .white)
StatBox(title: String(localized: "Win Rate"), value: formatPercent(winRate), color: winRate >= 50 ? .green : .orange)
StatBox(title: String(localized: "Net"), value: formatMoney(totalWinnings), color: totalWinnings >= 0 ? .green : .red)
StatBox(title: String(localized: "Balance"), value: "$\(state.balance)", color: Color.Sheet.accent)
}
}
// Tab selector (from CasinoKit)
StatisticsTabSelector(selectedTab: $selectedTab)
// Win Distribution
SheetSection(title: String(localized: "OUTCOMES"), icon: "chart.pie.fill") {
VStack(spacing: Design.Spacing.small) {
OutcomeRow(label: String(localized: "Blackjacks"), count: blackjacks, total: totalRounds, color: .yellow)
OutcomeRow(label: String(localized: "Wins"), count: wins - blackjacks, total: totalRounds, color: .green)
OutcomeRow(label: String(localized: "Pushes"), count: pushes, total: totalRounds, color: .blue)
OutcomeRow(label: String(localized: "Losses"), count: losses - busts, total: totalRounds, color: .orange)
OutcomeRow(label: String(localized: "Busts"), count: busts, total: totalRounds, color: .red)
if surrenders > 0 {
OutcomeRow(label: String(localized: "Surrenders"), count: surrenders, total: totalRounds, color: .gray)
}
}
}
// Biggest Swings
if totalRounds > 0 {
SheetSection(title: String(localized: "BIGGEST SWINGS"), icon: "arrow.up.arrow.down") {
HStack(spacing: Design.Spacing.large) {
VStack(spacing: Design.Spacing.xSmall) {
Text(String(localized: "Best"))
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text(formatMoney(biggestWin))
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded))
.foregroundStyle(.green)
}
.frame(maxWidth: .infinity)
Divider()
.frame(height: 40)
.background(Color.white.opacity(Design.Opacity.hint))
VStack(spacing: Design.Spacing.xSmall) {
Text(String(localized: "Worst"))
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text(formatMoney(biggestLoss))
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded))
.foregroundStyle(.red)
}
.frame(maxWidth: .infinity)
}
}
// Content based on selected tab
switch selectedTab {
case .current:
currentSessionContent
case .global:
globalStatsContent
case .history:
sessionHistoryContent
}
},
onCancel: nil,
onDone: { dismiss() },
doneButtonText: String(localized: "Done")
)
.confirmationDialog(
String(localized: "End Session?"),
isPresented: $state.showEndSessionConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "End Session"), role: .destructive) {
state.endSessionAndStartNew()
dismiss()
}
private func formatMoney(_ amount: Int) -> String {
if amount >= 0 {
return "+$\(amount)"
} else {
return "-$\(abs(amount))"
Button(String(localized: "Cancel"), role: .cancel) {}
} message: {
if let session = state.currentSession {
Text(String(localized: "You played \(session.roundsPlayed) hands with a net result of \(SessionFormatter.formatMoney(session.netResult)). This session will be saved to your history."))
}
}
private func formatPercent(_ value: Double) -> String {
value.formatted(.number.precision(.fractionLength(1))) + "%"
.sheet(item: $selectedSession) { session in
SessionDetailView(
session: session,
styleDisplayName: styleDisplayName(for: session.gameStyle),
onDelete: {
state.deleteSession(id: session.id)
selectedSession = nil
}
}
// MARK: - Stat Box
struct StatBox: View {
let title: String
let value: String
let color: Color
var body: some View {
VStack(spacing: Design.Spacing.xSmall) {
Text(title)
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text(value)
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded))
.foregroundStyle(color)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity)
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.white.opacity(Design.Opacity.subtle))
)
}
}
// MARK: - Outcome Row
struct OutcomeRow: View {
let label: String
let count: Int
let total: Int
let color: Color
private var percentage: Double {
guard total > 0 else { return 0 }
return Double(count) / Double(total) * 100
}
private func formatPercentWhole(_ value: Double) -> String {
value.formatted(.number.precision(.fractionLength(0))) + "%"
// MARK: - Tab Selector (uses CasinoKit.StatisticsTabSelector)
// MARK: - Current Session Content
@ViewBuilder
private var currentSessionContent: some View {
if let session = state.currentSession {
// Current session header
CurrentSessionHeader(
duration: session.duration,
roundsPlayed: session.roundsPlayed,
netResult: session.netResult,
onEndSession: {
state.showEndSessionConfirmation = true
}
)
.padding(.horizontal)
// Session stats
sessionStatsSection(session: session)
} else {
NoActiveSessionView()
}
}
var body: some View {
// MARK: - Global Stats Content
private var globalStatsContent: some View {
let stats = state.aggregatedStats
let gameStats = state.aggregatedBlackjackStats
return Group {
// Summary section
SheetSection(title: String(localized: "ALL TIME SUMMARY"), icon: "globe") {
VStack(spacing: Design.Spacing.large) {
// Sessions overview
HStack(spacing: Design.Spacing.large) {
StatColumn(
value: "\(stats.totalSessions)",
label: String(localized: "Sessions")
)
StatColumn(
value: "\(stats.totalRoundsPlayed)",
label: String(localized: "Hands")
)
StatColumn(
value: SessionFormatter.formatPercent(stats.winRate),
label: String(localized: "Win Rate"),
valueColor: stats.winRate >= 50 ? .green : .orange
)
}
Divider().background(Color.white.opacity(Design.Opacity.hint))
// Financial summary
HStack(spacing: Design.Spacing.large) {
StatColumn(
value: SessionFormatter.formatMoney(stats.totalWinnings),
label: String(localized: "Net"),
valueColor: stats.totalWinnings >= 0 ? .green : .red
)
StatColumn(
value: SessionFormatter.formatDuration(stats.totalPlayTime),
label: String(localized: "Time")
)
}
}
}
// Blackjack-specific stats
SheetSection(title: String(localized: "GAME STATS"), icon: "suit.spade.fill") {
VStack(spacing: Design.Spacing.medium) {
ForEach(gameStats.displayItems) { item in
GameStatRow(item: item)
}
}
}
// Session performance (from CasinoKit)
SheetSection(title: String(localized: "SESSION PERFORMANCE"), icon: "chart.bar.fill") {
SessionPerformanceSection(
winningSessions: stats.winningSessions,
losingSessions: stats.losingSessions,
bestSession: stats.bestSession,
worstSession: stats.worstSession
)
}
}
}
// MARK: - Session History Content
private var sessionHistoryContent: some View {
Group {
if state.sessionHistory.isEmpty && state.currentSession == nil {
EmptyHistoryView()
} else {
LazyVStack(spacing: Design.Spacing.medium) {
// Current session at top if exists (taps go to Current tab)
if let current = state.currentSession {
Button {
withAnimation {
selectedTab = .current
}
} label: {
SessionSummaryRow(
styleDisplayName: styleDisplayName(for: current.gameStyle),
duration: current.duration,
roundsPlayed: current.roundsPlayed,
netResult: current.netResult,
startTime: current.startTime,
isActive: true,
endReason: nil
)
}
.buttonStyle(.plain)
}
// Historical sessions - tap to view details, swipe to delete
ForEach(state.sessionHistory) { session in
Button {
selectedSession = session
} label: {
HStack {
// Label
Text(label)
SessionSummaryRow(
styleDisplayName: styleDisplayName(for: session.gameStyle),
duration: session.duration,
roundsPlayed: session.roundsPlayed,
netResult: session.netResult,
startTime: session.startTime,
isActive: false,
endReason: session.endReason
)
Image(systemName: "chevron.right")
.font(.system(size: Design.BaseFontSize.small, weight: .semibold))
.foregroundStyle(.white.opacity(Design.Opacity.light))
}
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
withAnimation {
state.deleteSession(id: session.id)
}
} label: {
Label(String(localized: "Delete"), systemImage: "trash")
}
}
}
}
.padding(.horizontal)
}
}
}
// MARK: - Session Stats Section
@ViewBuilder
private func sessionStatsSection(session: BlackjackSession) -> some View {
// Game stats section
SheetSection(title: String(localized: "IN GAME STATS"), icon: "gamecontroller.fill") {
VStack(spacing: Design.Spacing.large) {
// Hands played
VStack(spacing: Design.Spacing.xSmall) {
Text(String(localized: "Hands played"))
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
Text("\(session.roundsPlayed)")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
Spacer()
// Win/Loss/Push
HStack(spacing: Design.Spacing.medium) {
OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white)
OutcomeCircle(label: String(localized: "Lost"), count: session.losses, color: .red)
OutcomeCircle(label: String(localized: "Push"), count: session.pushes, color: .gray)
}
// Count
Text("\(count)")
.font(.system(size: Design.BaseFontSize.body, weight: .bold))
.foregroundStyle(color)
Divider().background(Color.white.opacity(Design.Opacity.hint))
// Progress bar
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
.fill(Color.white.opacity(Design.Opacity.subtle))
// Game time and Blackjack-specific stats
StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration))
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
.fill(color)
.frame(width: geometry.size.width * CGFloat(percentage / 100))
ForEach(session.gameStats.displayItems) { item in
GameStatRow(item: item)
}
}
.frame(width: 60, height: 8)
// Percentage
Text(formatPercentWhole(percentage))
.font(.system(size: Design.BaseFontSize.small, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
.frame(width: 40, alignment: .trailing)
}
.padding(.vertical, Design.Spacing.xSmall)
// Chips stats section (from CasinoKit)
SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") {
ChipsStatsSection(
totalWinnings: session.totalWinnings,
biggestWin: session.biggestWin,
biggestLoss: session.biggestLoss,
totalBetAmount: session.totalBetAmount,
averageBet: session.roundsPlayed > 0 ? session.averageBet : nil,
biggestBet: session.biggestBet
)
}
}
// MARK: - Helpers
private func styleDisplayName(for rawValue: String) -> String {
BlackjackStyle(rawValue: rawValue)?.displayName ?? rawValue.capitalized
}
}
// MARK: - Statistics Tab & Supporting Views
// StatisticsTab, StatColumn, OutcomeCircle, StatRow, ChipStatRow are now provided by CasinoKit
// MARK: - Session Detail View
private struct SessionDetailView: View {
let session: BlackjackSession
let styleDisplayName: String
let onDelete: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var showDeleteConfirmation = false
var body: some View {
SheetContainerView(
title: styleDisplayName,
content: {
// Session header info (from CasinoKit)
SessionDetailHeader(
startTime: session.startTime,
endReason: session.endReason,
netResult: session.netResult,
winRate: session.winRate
)
// Game stats section
SheetSection(title: String(localized: "GAME STATS"), icon: "gamecontroller.fill") {
VStack(spacing: Design.Spacing.large) {
// Hands played
VStack(spacing: Design.Spacing.xSmall) {
Text(String(localized: "Hands played"))
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
Text("\(session.roundsPlayed)")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
// Win/Loss/Push (from CasinoKit)
HStack(spacing: Design.Spacing.medium) {
OutcomeCircle(label: String(localized: "Won"), count: session.wins, color: .white)
OutcomeCircle(label: String(localized: "Lost"), count: session.losses, color: .red)
OutcomeCircle(label: String(localized: "Push"), count: session.pushes, color: .gray)
}
Divider().background(Color.white.opacity(Design.Opacity.hint))
// Game time (from CasinoKit)
StatRow(icon: "clock", label: String(localized: "Total game time"), value: SessionFormatter.formatDuration(session.duration))
// Blackjack-specific stats
ForEach(session.gameStats.displayItems) { item in
GameStatRow(item: item)
}
}
}
// Chips stats section (from CasinoKit)
SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") {
ChipsStatsSection(
totalWinnings: session.totalWinnings,
biggestWin: session.biggestWin,
biggestLoss: session.biggestLoss,
totalBetAmount: session.totalBetAmount,
averageBet: session.roundsPlayed > 0 ? session.averageBet : nil,
biggestBet: session.biggestBet
)
}
// Balance section (from CasinoKit)
SheetSection(title: String(localized: "BALANCE"), icon: "banknote.fill") {
BalanceSection(
startingBalance: session.startingBalance,
endingBalance: session.endingBalance,
netResult: session.netResult
)
}
// Delete button (from CasinoKit)
DeleteSessionButton {
showDeleteConfirmation = true
}
},
onCancel: nil,
onDone: { dismiss() },
doneButtonText: String(localized: "Done")
)
.confirmationDialog(
String(localized: "Delete Session?"),
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "Delete"), role: .destructive) {
onDelete()
dismiss()
}
Button(String(localized: "Cancel"), role: .cancel) {}
} message: {
Text(String(localized: "This will permanently remove this session from your history."))
}
}
}
// MARK: - Preview
#Preview {
StatisticsSheetView(state: GameState(settings: GameSettings()))
}

View File

@ -55,7 +55,6 @@ struct BettingZoneView: View {
}
var body: some View {
if state.settings.sideBetsEnabled {
// Horizontal layout: PP | Main Bet | 21+3
HStack(spacing: Design.Spacing.small) {
// Perfect Pairs
@ -82,11 +81,6 @@ struct BettingZoneView: View {
.frame(width: Design.Size.sideBetZoneWidth)
}
.frame(height: zoneHeight)
} else {
// Simple layout: just main bet
mainBetZone
.frame(height: zoneHeight)
}
}
private var mainBetZone: some View {
@ -155,14 +149,12 @@ extension Design.Size {
}
}
#Preview("With Side Bets") {
#Preview("Main Bet Only") {
ZStack {
Color.Table.felt.ignoresSafeArea()
BettingZoneView(
state: {
let settings = GameSettings()
settings.sideBetsEnabled = true
let state = GameState(settings: settings)
let state = GameState(settings: GameSettings())
state.placeBet(amount: 100)
return state
}(),
@ -177,9 +169,7 @@ extension Design.Size {
Color.Table.felt.ignoresSafeArea()
BettingZoneView(
state: {
let settings = GameSettings()
settings.sideBetsEnabled = true
let state = GameState(settings: settings)
let state = GameState(settings: GameSettings())
state.placeBet(amount: 250)
state.placeSideBet(type: .perfectPairs, amount: 25)
state.placeSideBet(type: .twentyOnePlusThree, amount: 50)

View File

@ -116,10 +116,11 @@ struct BlackjackTableView: View {
Spacer(minLength: Design.Spacing.small)
.debugBorder(showDebugBorders, color: .yellow, label: "BottomSpacer")
// Player hands area - only show when there are cards dealt
if state.playerHands.first?.cards.isEmpty == false {
// Player hands area - show when not betting (includes dealing phase)
// This ensures the container with placeholders is visible BEFORE cards fly in
if state.currentPhase != .betting {
ZStack {
PlayerHandsView(
PlayerHandsContainer(
hands: state.playerHands,
activeHandIndex: state.activeHandIndex,
isPlayerTurn: state.isPlayerTurn,
@ -134,7 +135,7 @@ struct BlackjackTableView: View {
)
// Side bet toasts (positioned on left/right sides to not cover cards)
if state.settings.sideBetsEnabled && state.showSideBetToasts {
if state.showSideBetToasts {
HStack {
// PP on left
if state.perfectPairsBet > 0, let ppResult = state.perfectPairsResult {

View File

@ -0,0 +1,252 @@
//
// CardStackView.swift
// Blackjack
//
// Shared card stack display for dealer and player hands.
//
import SwiftUI
import CasinoKit
/// A reusable view that displays a stack of cards with animations.
/// Used by both DealerHandView and PlayerHandView.
struct CardStackView: View {
let cards: [Card]
let cardWidth: CGFloat
let cardSpacing: CGFloat
let showAnimations: Bool
let dealingSpeed: Double
let showCardCount: Bool
/// Determines if a card at a given index should be face up.
/// For dealer: `{ index in index == 0 || showHoleCard }`
/// For player: `{ _ in true }`
let isFaceUp: (Int) -> Bool
/// Animation offset for dealing cards (direction cards fly in from).
let dealOffset: CGPoint
/// Minimum number of card slots to reserve space for.
/// This prevents width changes during dealing by pre-allocating placeholder space.
let minimumCardSlots: Int
/// Whether placeholders should use staggered (overlapped) layout like dealt cards.
/// - `true`: Placeholders overlap using `cardSpacing` (use for bordered containers like player hand)
/// - `false`: Placeholders side-by-side (use for borderless containers like dealer hand)
let staggeredPlaceholders: Bool
/// Number of additional placeholders needed to reach minimum slots.
private var placeholdersNeeded: Int {
max(0, minimumCardSlots - cards.count)
}
/// Spacing to use for empty placeholder state
private var placeholderSpacing: CGFloat {
staggeredPlaceholders ? cardSpacing : Design.Spacing.small
}
var body: some View {
// Use staggered or side-by-side spacing based on configuration
HStack(spacing: cards.isEmpty && placeholdersNeeded > 0 ? placeholderSpacing : cardSpacing) {
if cards.isEmpty && placeholdersNeeded > 0 {
// Show placeholders when empty
ForEach(0..<placeholdersNeeded, id: \.self) { index in
CardPlaceholderView(width: cardWidth)
.zIndex(Double(index))
}
} else {
// Show actual cards
ForEach(cards.indices, id: \.self) { index in
let faceUp = isFaceUp(index)
CardView(
card: cards[index],
isFaceUp: faceUp,
cardWidth: cardWidth
)
.overlay(alignment: .bottomLeading) {
if showCardCount && faceUp {
HiLoCountBadge(card: cards[index])
}
}
.zIndex(Double(index))
.transition(
showAnimations
? .asymmetric(
insertion: .offset(x: dealOffset.x, y: dealOffset.y)
.combined(with: .opacity)
.combined(with: .scale(scale: Design.Scale.slightShrink)),
removal: .scale.combined(with: .opacity)
)
: .identity
)
}
// Show trailing placeholders to reserve space for upcoming cards
ForEach(0..<placeholdersNeeded, id: \.self) { index in
CardPlaceholderView(width: cardWidth)
.opacity(Design.Opacity.medium)
.zIndex(Double(cards.count + index))
}
}
}
.animation(
showAnimations
? CasinoDesign.Animation.cardDeal(speed: dealingSpeed)
: .none,
value: cards.count
)
}
}
// MARK: - Convenience Initializers
extension CardStackView {
/// Creates a card stack for the dealer (with hole card support).
/// Uses side-by-side placeholders since dealer area has no border.
static func dealer(
cards: [Card],
showHoleCard: Bool,
cardWidth: CGFloat,
cardSpacing: CGFloat,
showAnimations: Bool,
dealingSpeed: Double,
showCardCount: Bool
) -> CardStackView {
CardStackView(
cards: cards,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
showCardCount: showCardCount,
isFaceUp: { index in index == 0 || showHoleCard },
dealOffset: CGPoint(
x: Design.DealAnimation.dealerOffsetX,
y: Design.DealAnimation.dealerOffsetY
),
minimumCardSlots: 2,
staggeredPlaceholders: false // Side-by-side (no border to show shrink)
)
}
/// Creates a card stack for the player (all cards face up).
/// Uses staggered placeholders to match dealt card layout (prevents container shrink).
/// - Parameter minimumCardSlots: Minimum slots to reserve (default 2 for initial deal).
static func player(
cards: [Card],
cardWidth: CGFloat,
cardSpacing: CGFloat,
showAnimations: Bool,
dealingSpeed: Double,
showCardCount: Bool,
minimumCardSlots: Int = 2
) -> CardStackView {
CardStackView(
cards: cards,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
showCardCount: showCardCount,
isFaceUp: { _ in true },
dealOffset: CGPoint(
x: Design.DealAnimation.playerOffsetX,
y: Design.DealAnimation.playerOffsetY
),
minimumCardSlots: minimumCardSlots,
staggeredPlaceholders: true // Staggered (bordered container shows shrink)
)
}
}
// MARK: - Previews
#Preview("Empty - Staggered") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView(
cards: [],
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: false,
isFaceUp: { _ in true },
dealOffset: .zero,
minimumCardSlots: 2,
staggeredPlaceholders: true
)
}
}
#Preview("Empty - Side by Side") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView(
cards: [],
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: false,
isFaceUp: { _ in true },
dealOffset: .zero,
minimumCardSlots: 2,
staggeredPlaceholders: false
)
}
}
#Preview("Player Cards") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView.player(
cards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .spades, rank: .king)
],
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: true
)
}
}
#Preview("Dealer - Hole Hidden") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView.dealer(
cards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .spades, rank: .king)
],
showHoleCard: false,
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: true
)
}
}
#Preview("Dealer - Hole Revealed") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView.dealer(
cards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .spades, rank: .king)
],
showHoleCard: true,
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: true
)
}
}

View File

@ -2,7 +2,7 @@
// DealerHandView.swift
// Blackjack
//
// Displays the dealer's hand with cards and value.
// Displays the dealer's hand with label, value badge, and cards.
//
import SwiftUI
@ -21,87 +21,52 @@ struct DealerHandView: View {
let visibleCardCount: Int
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
@ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge
/// Scaled animation duration based on dealing speed.
private var animationDuration: Double {
Design.Animation.springDuration * dealingSpeed
/// The value text to display in the badge (based on visible cards and hole card state).
/// Shows soft values like "7/17" for consistency with player hand display.
private var displayValueText: String? {
guard !hand.cards.isEmpty && visibleCardCount > 0 else { return nil }
if showHoleCard {
// Hole card revealed - calculate value from visible cards
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
let (hardValue, softValue) = BlackjackHand.calculateValues(for: visibleCards)
let hasSoftAce = BlackjackHand.hasSoftAce(for: visibleCards)
// When soft value is 21, there's no ambiguity - just show 21
if softValue == 21 {
return "21"
} else if hasSoftAce {
return "\(hardValue)/\(softValue)"
} else {
return "\(BlackjackHand.bestValue(for: visibleCards))"
}
} else {
// Hole card hidden - show only the first (face-up) card's value
return "\(hand.cards[0].blackjackValue)"
}
}
var body: some View {
VStack(spacing: Design.Spacing.small) {
// Label and value - fixed height prevents vertical layout shift
HStack(spacing: Design.Spacing.small) {
Text(String(localized: "DEALER"))
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(.white)
// Calculate value from visible cards only
if !hand.cards.isEmpty && visibleCardCount > 0 {
if showHoleCard && visibleCardCount >= hand.cards.count {
// All cards visible - calculate total hand value from visible cards
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
let visibleValue = visibleCards.reduce(0) { $0 + $1.blackjackValue }
let visibleHasSoftAce = visibleCards.contains { $0.rank == .ace } && visibleValue + 10 <= 21
let displayValue = visibleHasSoftAce && visibleValue + 10 <= 21 ? visibleValue + 10 : visibleValue
ValueBadge(value: displayValue, color: Color.Hand.dealer)
.animation(nil, value: displayValue) // No animation when value changes
} else if visibleCardCount >= 1 {
// Hole card hidden or not all cards visible - show only the first (face-up) card's value
ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer)
.animation(nil, value: hand.cards[0].blackjackValue) // No animation when value changes
}
}
}
.frame(minHeight: badgeHeight) // Reserve consistent height
// Remove animations on badge appearance/value changes
// Label and value badge
HandLabelView(
title: String(localized: "DEALER"),
valueText: displayValueText,
badgeColor: Color.Hand.dealer
)
.animation(nil, value: visibleCardCount)
.animation(nil, value: showHoleCard)
// Cards with result badge overlay (overlay prevents height change)
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
if hand.cards.isEmpty {
CardPlaceholderView(width: cardWidth)
CardPlaceholderView(width: cardWidth)
} else {
ForEach(hand.cards.indices, id: \.self) { index in
let isFaceUp = index == 0 || showHoleCard
CardView(
card: hand.cards[index],
isFaceUp: isFaceUp,
cardWidth: cardWidth
)
.overlay(alignment: .bottomLeading) {
if showCardCount && isFaceUp {
HiLoCountBadge(card: hand.cards[index])
}
}
.zIndex(Double(index))
.transition(
showAnimations
? .asymmetric(
insertion: .offset(x: Design.DealAnimation.dealerOffsetX, y: Design.DealAnimation.dealerOffsetY)
.combined(with: .opacity)
.combined(with: .scale(scale: Design.Scale.slightShrink)),
removal: .scale.combined(with: .opacity)
)
: .identity
)
}
// Show placeholder for second card in European mode (no hole card)
if hand.cards.count == 1 && !showHoleCard {
CardPlaceholderView(width: cardWidth)
.opacity(Design.Opacity.medium)
}
}
}
.animation(
showAnimations
? .spring(duration: animationDuration, bounce: Design.Animation.springBounce)
: .none,
value: hand.cards.count
// Cards with result badge overlay
CardStackView.dealer(
cards: hand.cards,
showHoleCard: showHoleCard,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
showCardCount: showCardCount
)
.overlay {
// Result badge - centered on cards
@ -152,6 +117,8 @@ struct DealerHandView: View {
}
}
// MARK: - Previews
#Preview("Empty Hand") {
ZStack {
Color.Table.felt.ignoresSafeArea()
@ -205,4 +172,3 @@ struct DealerHandView: View {
)
}
}

View File

@ -0,0 +1,107 @@
//
// HandLabelView.swift
// Blackjack
//
// Shared label component for hand displays (dealer and player).
// Shows title text with an optional value badge.
//
import SwiftUI
import CasinoKit
/// Displays a hand label with title and value badge.
/// Used by both DealerHandView and PlayerHandView for consistent appearance.
struct HandLabelView: View {
let title: String
let value: Int?
let valueText: String?
let badgeColor: Color
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
@ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge
/// Creates a hand label with a numeric value badge.
init(title: String, value: Int?, badgeColor: Color) {
self.title = title
self.value = value
self.valueText = nil
self.badgeColor = badgeColor
}
/// Creates a hand label with a text value badge (for soft hands like "8/18").
init(title: String, valueText: String?, badgeColor: Color) {
self.title = title
self.value = nil
self.valueText = valueText
self.badgeColor = badgeColor
}
var body: some View {
HStack(spacing: Design.Spacing.small) {
Text(title)
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if let value = value {
ValueBadge(value: value, color: badgeColor)
} else if let text = valueText, !text.isEmpty {
TextValueBadge(text: text, color: badgeColor)
}
}
.fixedSize() // Prevent the label from being constrained/truncated
.frame(minHeight: badgeHeight)
.animation(nil, value: value)
.animation(nil, value: valueText)
}
}
/// A badge that displays text (for soft hand values like "8/18").
struct TextValueBadge: View {
let text: String
let color: Color
// Match ValueBadge styling from CasinoKit
@ScaledMetric(relativeTo: .headline) private var fontSize: CGFloat = 15
@ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge
@ScaledMetric(relativeTo: .headline) private var badgePadding: CGFloat = 8
var body: some View {
Text(text)
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false) // Prevent truncation
.padding(.horizontal, badgePadding)
.frame(minHeight: badgeHeight)
.background(
Capsule()
.fill(color)
)
}
}
// MARK: - Previews
#Preview("Dealer Label") {
ZStack {
Color.Table.felt.ignoresSafeArea()
VStack(spacing: Design.Spacing.large) {
HandLabelView(title: "DEALER", value: 21, badgeColor: Color.Hand.dealer)
HandLabelView(title: "DEALER", value: 17, badgeColor: Color.Hand.dealer)
HandLabelView(title: "DEALER", value: nil, badgeColor: Color.Hand.dealer)
}
}
}
#Preview("Player Label") {
ZStack {
Color.Table.felt.ignoresSafeArea()
VStack(spacing: Design.Spacing.large) {
HandLabelView(title: "PLAYER", value: 21, badgeColor: Color.Hand.player)
HandLabelView(title: "PLAYER", valueText: "8/18", badgeColor: Color.Hand.player)
HandLabelView(title: "Hand 1", value: 17, badgeColor: Color.Hand.player)
HandLabelView(title: "Hand 2", valueText: "5/15", badgeColor: Color.Hand.active)
}
}
}

View File

@ -2,103 +2,12 @@
// PlayerHandView.swift
// Blackjack
//
// Displays player hands in a horizontally scrollable container.
// Displays a single player hand with label, value badge, cards, and bet.
//
import SwiftUI
import CasinoKit
// MARK: - Player Hands Container
/// Container for multiple player hands with horizontal scrolling.
struct PlayerHandsView: View {
let hands: [BlackjackHand]
let activeHandIndex: Int
let isPlayerTurn: Bool
let showCardCount: Bool
let showAnimations: Bool
let dealingSpeed: Double
let cardWidth: CGFloat
let cardSpacing: CGFloat
/// Number of visible cards for each hand (completed animations)
let visibleCardCounts: [Int]
/// Current hint to display (shown on active hand only).
let currentHint: String?
/// Whether the hint toast should be visible.
let showHintToast: Bool
/// Total card count across all hands - used to trigger scroll when hitting
private var totalCardCount: Int {
hands.reduce(0) { $0 + $1.cards.count }
}
var body: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Design.Spacing.large) {
// Display hands in reverse order (right to left play order)
// Visual order: Hand 3, Hand 2, Hand 1 (left to right)
// Play order: Hand 1 played first (rightmost), then Hand 2, etc.
ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in
let isActiveHand = index == activeHandIndex && isPlayerTurn
let visibleCount = index < visibleCardCounts.count ? visibleCardCounts[index] : 0
PlayerHandView(
hand: hand,
isActive: isActiveHand,
showCardCount: showCardCount,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
// Hand numbers: rightmost (index 0) is Hand 1, played first
handNumber: hands.count > 1 ? index + 1 : nil,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
visibleCardCount: visibleCount,
// Only show hint on the active hand
currentHint: isActiveHand ? currentHint : nil,
showHintToast: isActiveHand && showHintToast
)
.id(hand.id)
.transition(.scale.combined(with: .opacity))
}
}
.animation(.spring(duration: Design.Animation.springDuration), value: hands.count)
.padding(.horizontal, Design.Spacing.xxLarge) // More padding for scrolling
}
.scrollClipDisabled()
.scrollBounceBehavior(.always) // Always allow bouncing for better scroll feel
.defaultScrollAnchor(.center) // Center the content by default
.onChange(of: activeHandIndex) { _, newIndex in
scrollToActiveHand(proxy: proxy)
}
.onChange(of: totalCardCount) { _, _ in
// Scroll to active hand when cards are added (hit)
scrollToActiveHand(proxy: proxy)
}
.onChange(of: hands.count) { _, _ in
// Scroll to active hand when split occurs
scrollToActiveHand(proxy: proxy)
}
.onAppear {
scrollToActiveHand(proxy: proxy)
}
}
.frame(maxWidth: .infinity)
}
private func scrollToActiveHand(proxy: ScrollViewProxy) {
guard activeHandIndex < hands.count else { return }
let activeHandId = hands[activeHandIndex].id
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
proxy.scrollTo(activeHandId, anchor: .center)
}
}
}
// MARK: - Single Player Hand
/// Displays a single player hand with cards, value, and result.
struct PlayerHandView: View {
let hand: BlackjackHand
@ -119,55 +28,70 @@ struct PlayerHandView: View {
/// Whether the hint toast should be visible.
let showHintToast: Bool
/// Scaled animation duration based on dealing speed.
private var animationDuration: Double {
Design.Animation.springDuration * dealingSpeed
}
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
@ScaledMetric(relativeTo: .caption) private var handNumberSize: CGFloat = Design.Size.handNumberFontSize
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.Size.handIconSize
@ScaledMetric(relativeTo: .body) private var hintPaddingH: CGFloat = Design.Size.hintPaddingH
@ScaledMetric(relativeTo: .body) private var hintPaddingV: CGFloat = Design.Size.hintPaddingV
/// The title to display (PLAYER or Hand #).
private var displayTitle: String {
if let number = handNumber {
return String(localized: "Hand \(number)")
}
return String(localized: "PLAYER")
}
/// The badge color (active hands get highlight color).
private var badgeColor: Color {
isActive ? Color.Hand.active : Color.Hand.player
}
/// Calculates display info for visible cards using shared BlackjackHand logic.
private var visibleCardsDisplayInfo: (text: String, value: Int, isBusted: Bool)? {
guard !hand.cards.isEmpty else { return nil }
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
guard !visibleCards.isEmpty else { return nil }
// Use shared static methods for value calculation
let (hardValue, softValue) = BlackjackHand.calculateValues(for: visibleCards)
let displayValue = BlackjackHand.bestValue(for: visibleCards)
let hasSoftAce = BlackjackHand.hasSoftAce(for: visibleCards)
let isBusted = hardValue > 21
// Show value like hand.valueDisplay does (e.g., "8/18" for soft hands)
// When soft value is 21, there's no ambiguity - just show 21
let valueText: String
if softValue == 21 {
valueText = "21"
} else if hasSoftAce {
valueText = "\(hardValue)/\(softValue)"
} else {
valueText = "\(displayValue)"
}
return (valueText, displayValue, isBusted)
}
var body: some View {
VStack(spacing: Design.Spacing.small) {
// Label and value badge at TOP (consistent with dealer)
HandLabelView(
title: displayTitle,
valueText: visibleCardsDisplayInfo?.text,
badgeColor: badgeColor
)
.animation(nil, value: visibleCardCount)
// Cards with container
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
if hand.cards.isEmpty {
CardPlaceholderView(width: cardWidth)
CardPlaceholderView(width: cardWidth)
} else {
ForEach(hand.cards.indices, id: \.self) { index in
CardView(
card: hand.cards[index],
isFaceUp: true,
cardWidth: cardWidth
)
.overlay(alignment: .bottomLeading) {
if showCardCount {
HiLoCountBadge(card: hand.cards[index])
}
}
.zIndex(Double(index))
.transition(
showAnimations
? .asymmetric(
insertion: .offset(x: Design.DealAnimation.playerOffsetX, y: Design.DealAnimation.playerOffsetY)
.combined(with: .opacity)
.combined(with: .scale(scale: Design.Scale.slightShrink)),
removal: .scale.combined(with: .opacity)
)
: .identity
)
}
}
}
.animation(
showAnimations
? .spring(duration: animationDuration, bounce: Design.Animation.springBounce)
: .none,
value: hand.cards.count
CardStackView.player(
cards: hand.cards,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
showCardCount: showCardCount
)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.medium)
@ -206,53 +130,9 @@ struct PlayerHandView: View {
.animation(.easeInOut(duration: Design.Animation.quick), value: isActive)
.animation(.spring(duration: Design.Animation.springDuration), value: hand.result != nil)
// Hand info
// Bet amount and doubled down indicator
if hand.bet > 0 || hand.isDoubledDown {
HStack(spacing: Design.Spacing.small) {
if let number = handNumber {
Text(String(localized: "Hand \(number)"))
.font(.system(size: handNumberSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
// Calculate value from visible (animation-completed) cards
// Always show the value - it updates as cards become visible
if !hand.cards.isEmpty {
// Use only the cards that have completed their animation
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
let visibleValue = visibleCards.reduce(0) { $0 + $1.blackjackValue }
let visibleHasSoftAce = visibleCards.contains { $0.rank == .ace } && visibleValue + 10 <= 21
let displayValue = visibleHasSoftAce && visibleValue + 10 <= 21 ? visibleValue + 10 : visibleValue
// Determine color based on visible cards
let isVisibleBlackjack = visibleCards.count == 2 && displayValue == 21
let isVisibleBusted = visibleValue > 21
let isVisible21 = displayValue == 21 && !isVisibleBlackjack
let displayColor: Color = {
if isVisibleBlackjack { return .yellow }
if isVisibleBusted { return .red }
if isVisible21 { return .green }
return .white
}()
// Show value like hand.valueDisplay does
let valueText = visibleHasSoftAce ? "\(visibleValue)/\(displayValue)" : "\(displayValue)"
Text(valueText)
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(displayColor)
.animation(nil, value: valueText) // No animation when text changes
.animation(nil, value: displayColor) // No animation when color changes
}
if hand.isDoubledDown {
Image(systemName: "xmark.circle.fill")
.font(.system(size: iconSize))
.foregroundStyle(.purple)
}
}
// Bet amount
if hand.bet > 0 {
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "dollarsign.circle.fill")
@ -263,6 +143,14 @@ struct PlayerHandView: View {
.foregroundStyle(.yellow)
}
}
if hand.isDoubledDown {
Image(systemName: "xmark.circle.fill")
.font(.system(size: iconSize))
.foregroundStyle(.purple)
}
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(playerAccessibilityLabel)
@ -270,13 +158,6 @@ struct PlayerHandView: View {
// MARK: - Computed Properties
private var valueColor: Color {
if hand.isBlackjack { return .yellow }
if hand.isBusted { return .red }
if hand.value == 21 { return .green }
return .white
}
private var playerAccessibilityLabel: String {
let cardsDescription = hand.cards.map { $0.accessibilityDescription }.joined(separator: ", ")
var label = String(localized: "Player hand: \(cardsDescription). Value: \(hand.valueDisplay)")
@ -289,80 +170,87 @@ struct PlayerHandView: View {
// MARK: - Previews
#Preview("Single Hand - Empty") {
#Preview("Empty Hand") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerHandsView(
hands: [BlackjackHand()],
activeHandIndex: 0,
isPlayerTurn: true,
PlayerHandView(
hand: BlackjackHand(),
isActive: true,
showCardCount: false,
showAnimations: true,
dealingSpeed: 1.0,
handNumber: nil,
cardWidth: 60,
cardSpacing: -20,
visibleCardCounts: [0],
visibleCardCount: 0,
currentHint: nil,
showHintToast: false
)
}
}
#Preview("Single Hand - Cards") {
#Preview("With Cards - Active") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerHandsView(
hands: [BlackjackHand(cards: [
PlayerHandView(
hand: BlackjackHand(cards: [
Card(suit: .clubs, rank: .eight),
Card(suit: .hearts, rank: .nine)
], bet: 100)],
activeHandIndex: 0,
isPlayerTurn: true,
], bet: 100),
isActive: true,
showCardCount: false,
showAnimations: true,
dealingSpeed: 1.0,
handNumber: nil,
cardWidth: 60,
cardSpacing: -20,
visibleCardCounts: [2],
visibleCardCount: 2,
currentHint: "Hit",
showHintToast: true
)
}
}
#Preview("Split Hands") {
#Preview("Soft Hand") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerHandsView(
hands: [
BlackjackHand(cards: [
Card(suit: .clubs, rank: .eight),
Card(suit: .spades, rank: .jack)
PlayerHandView(
hand: BlackjackHand(cards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .spades, rank: .seven)
], bet: 100),
BlackjackHand(cards: [
Card(suit: .hearts, rank: .eight),
Card(suit: .diamonds, rank: .five)
], bet: 100),
BlackjackHand(cards: [
Card(suit: .hearts, rank: .eight),
Card(suit: .diamonds, rank: .five)
], bet: 100),
BlackjackHand(cards: [
Card(suit: .hearts, rank: .eight),
Card(suit: .diamonds, rank: .five)
], bet: 100)
],
activeHandIndex: 1,
isPlayerTurn: true,
isActive: true,
showCardCount: true,
showAnimations: true,
dealingSpeed: 1.0,
handNumber: nil,
cardWidth: 60,
cardSpacing: -20,
visibleCardCounts: [2, 2, 2, 2],
currentHint: "Stand",
showHintToast: true
visibleCardCount: 2,
currentHint: nil,
showHintToast: false
)
}
}
#Preview("Split Hand - Inactive") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerHandView(
hand: BlackjackHand(cards: [
Card(suit: .clubs, rank: .eight),
Card(suit: .spades, rank: .jack)
], bet: 100),
isActive: false,
showCardCount: false,
showAnimations: true,
dealingSpeed: 1.0,
handNumber: 2,
cardWidth: 60,
cardSpacing: -20,
visibleCardCount: 2,
currentHint: nil,
showHintToast: false
)
}
}

View File

@ -0,0 +1,184 @@
//
// PlayerHandsContainer.swift
// Blackjack
//
// Scrollable container for player hands (supports split hands).
//
import SwiftUI
import CasinoKit
/// Horizontally scrollable container that displays one or more player hands.
/// Handles split hands by showing them side-by-side with auto-scrolling to the active hand.
struct PlayerHandsContainer: View {
let hands: [BlackjackHand]
let activeHandIndex: Int
let isPlayerTurn: Bool
let showCardCount: Bool
let showAnimations: Bool
let dealingSpeed: Double
let cardWidth: CGFloat
let cardSpacing: CGFloat
/// Number of visible cards for each hand (completed animations)
let visibleCardCounts: [Int]
/// Current hint to display (shown on active hand only).
let currentHint: String?
/// Whether the hint toast should be visible.
let showHintToast: Bool
/// Tracks the currently scrolled-to hand ID for smooth positioning
@State private var scrolledHandID: UUID?
/// The active hand's ID, used for scroll position binding
private var activeHandID: UUID? {
guard activeHandIndex < hands.count else { return nil }
return hands[activeHandIndex].id
}
/// Total card count across all hands - used for animation
private var totalCardCount: Int {
hands.reduce(0) { $0 + $1.cards.count }
}
/// The hands content HStack
private var handsContent: some View {
HStack(spacing: Design.Spacing.large) {
// Display hands in reverse order (right to left play order)
// Visual order: Hand 3, Hand 2, Hand 1 (left to right)
// Play order: Hand 1 played first (rightmost), then Hand 2, etc.
ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in
let isActiveHand = index == activeHandIndex && isPlayerTurn
let visibleCount = index < visibleCardCounts.count ? visibleCardCounts[index] : 0
PlayerHandView(
hand: hand,
isActive: isActiveHand,
showCardCount: showCardCount,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
// Hand numbers: rightmost (index 0) is Hand 1, played first
handNumber: hands.count > 1 ? index + 1 : nil,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
visibleCardCount: visibleCount,
// Only show hint on the active hand
currentHint: isActiveHand ? currentHint : nil,
showHintToast: isActiveHand && showHintToast
)
.id(hand.id)
.transition(.scale.combined(with: .opacity))
}
}
.animation(.spring(duration: Design.Animation.springDuration), value: hands.count)
// Animate card count changes so width growth is smooth
.animation(.spring(duration: Design.Animation.springDuration), value: totalCardCount)
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
handsContent
.padding(.horizontal, Design.Spacing.xxLarge)
// Only use containerRelativeFrame for single hand (centering)
// For multiple hands, allow natural scrolling
.modifier(CenterSingleHandModifier(isSingleHand: hands.count == 1))
.scrollTargetLayout()
}
.scrollClipDisabled()
.scrollBounceBehavior(.always)
.scrollPosition(id: $scrolledHandID, anchor: .center)
.scrollTargetBehavior(.viewAligned)
.onChange(of: activeHandID) { _, newID in
// Animate scroll when switching between split hands
if let newID {
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
scrolledHandID = newID
}
}
}
.onChange(of: hands.count) { _, _ in
// Re-center when hands are added (split)
if let activeID = activeHandID {
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
scrolledHandID = activeID
}
}
}
.onAppear {
// Set initial position without animation
scrolledHandID = activeHandID
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Centering Modifier
/// Conditionally applies containerRelativeFrame for centering single hands.
/// For multiple hands, allows natural content width for scrolling.
private struct CenterSingleHandModifier: ViewModifier {
let isSingleHand: Bool
func body(content: Content) -> some View {
if isSingleHand {
content
.containerRelativeFrame(.horizontal, alignment: .center)
} else {
content
}
}
}
// MARK: - Previews
#Preview("Single Hand") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerHandsContainer(
hands: [BlackjackHand(cards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .spades, rank: .king)
], bet: 100)],
activeHandIndex: 0,
isPlayerTurn: true,
showCardCount: false,
showAnimations: true,
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20,
visibleCardCounts: [2],
currentHint: "Stand",
showHintToast: true
)
}
}
#Preview("Split Hands") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerHandsContainer(
hands: [
BlackjackHand(cards: [
Card(suit: .clubs, rank: .eight),
Card(suit: .spades, rank: .jack)
], bet: 100),
BlackjackHand(cards: [
Card(suit: .hearts, rank: .eight),
Card(suit: .diamonds, rank: .five)
], bet: 100)
],
activeHandIndex: 1,
isPlayerTurn: true,
showCardCount: true,
showAnimations: true,
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20,
visibleCardCounts: [2, 2],
currentHint: "Hit",
showHintToast: true
)
}
}

View File

@ -56,11 +56,24 @@ Professional-grade tools for learning the Hi-Lo system:
- **Betting Hints** — Recommendations based on count advantage
- **Illustrious 18** — Count-adjusted strategy deviations
### 📊 Statistics Tracking
- Win rate, blackjack count, bust rate
- Session net profit/loss
- Biggest wins and losses
- Complete round history
### 📊 Session-Based Statistics
Track your play sessions like a real casino visit:
- **Current Session** — Live stats for your active session
- **Global Stats** — Aggregated lifetime statistics
- **Session History** — Review past sessions with detailed breakdowns
**Per-Session Tracking:**
- Duration and hands played
- Win/loss/push breakdown
- Net result and win rate
- Blackjacks, busts, doubles, splits
- Average and biggest bets
**Session Management:**
- End a session manually or when you run out of chips
- Stats persisted across game styles (Vegas Strip, Atlantic City, etc.)
- Complete round history within each session
### ☁️ iCloud Sync
- Balance and statistics sync across devices
@ -82,7 +95,7 @@ Blackjack/
│ ├── Hand.swift # BlackjackHand model
│ └── SideBet.swift # Side bet types and evaluation
├── Storage/
│ └── BlackjackGameData.swift # Persistence models
│ └── BlackjackGameData.swift # Persistence and session models
├── Theme/
│ └── DesignConstants.swift # Design system tokens
├── Views/

View File

@ -479,6 +479,91 @@ sound.hapticError() // Error notification
sound.hapticWarning() // Warning notification
```
### 📊 Session Management
**GameSession** - Track play sessions with common and game-specific stats.
```swift
// Create a game-specific stats type
struct MyGameStats: GameSpecificStats {
var specialWins: Int = 0
init() {}
var displayItems: [StatDisplayItem] {
[StatDisplayItem(icon: "star.fill", iconColor: .yellow,
label: "Special Wins", value: "\(specialWins)")]
}
}
// Create session type alias
typealias MyGameSession = GameSession<MyGameStats>
```
**SessionManagedGame Protocol** - Add session management to your game state.
```swift
@Observable
class GameState: SessionManagedGame {
typealias Stats = MyGameStats
var currentSession: MyGameSession?
var sessionHistory: [MyGameSession] = []
// Record round results
func completeRound(winnings: Int, bet: Int) {
recordSessionRound(
winnings: winnings,
betAmount: bet,
outcome: winnings > 0 ? .win : .lose
) { stats in
if wasSpecialWin {
stats.specialWins += 1
}
}
}
}
```
**Session UI Components:**
```swift
// End session button
EndSessionButton {
state.showEndSessionConfirmation = true
}
// Current session header with live stats
CurrentSessionHeader(
duration: session.duration,
roundsPlayed: session.roundsPlayed,
netResult: session.netResult,
onEndSession: { /* confirm */ }
)
// Session row for history list
SessionSummaryRow(
styleDisplayName: "Vegas Strip",
duration: session.duration,
roundsPlayed: session.roundsPlayed,
netResult: session.netResult,
startTime: session.startTime,
isActive: false,
endReason: .manualEnd
)
```
**SessionFormatter** - Format session data for display.
```swift
SessionFormatter.formatDuration(3600) // "01h 00min"
SessionFormatter.formatMoney(500) // "$500"
SessionFormatter.formatMoney(-250) // "-$250"
SessionFormatter.formatPercent(65.5) // "65.5%"
```
For detailed documentation, see [SESSION_SYSTEM.md](SESSION_SYSTEM.md).
### 💾 Cloud Storage
**CloudSyncManager** - Saves game data locally and syncs with iCloud.
@ -681,6 +766,7 @@ CasinoKit/
├── Package.swift
├── README.md
├── GAME_TEMPLATE.md # Guide for creating new games
├── SESSION_SYSTEM.md # Session tracking documentation
├── Sources/CasinoKit/
│ ├── CasinoKit.swift
│ ├── Exports.swift
@ -690,7 +776,11 @@ CasinoKit/
│ │ ├── ChipDenomination.swift
│ │ ├── TableLimits.swift # Betting limit presets
│ │ ├── OnboardingState.swift # Onboarding tracking
│ │ └── TooltipManager.swift # Tooltip management
│ │ ├── TooltipManager.swift # Tooltip management
│ │ └── Session/
│ │ ├── GameSession.swift # Generic session with stats
│ │ ├── GameSessionProtocol.swift # Session protocols
│ │ └── SessionFormatter.swift # Formatting utilities
│ ├── Views/
│ │ ├── Cards/
│ │ │ ├── CardView.swift
@ -724,8 +814,10 @@ CasinoKit/
│ │ │ └── ActionButton.swift # Deal/Hit/Stand buttons
│ │ ├── Zones/
│ │ │ └── BettingZone.swift # Tappable betting area
│ │ └── Settings/
│ │ └── SettingsComponents.swift # Toggle, pickers
│ │ ├── Settings/
│ │ │ └── SettingsComponents.swift # Toggle, pickers
│ │ └── Session/
│ │ └── SessionViews.swift # Session UI components
│ ├── Audio/
│ │ └── SoundManager.swift
│ ├── Storage/
@ -793,6 +885,13 @@ private var fontSize: CGFloat = CasinoDesign.BaseFontSize.body
## Version History
- **1.1.0** - Session system
- Generic `GameSession<Stats>` for tracking play sessions
- `SessionManagedGame` protocol for easy integration
- Session UI components (header, summary rows, end button)
- `SessionFormatter` for consistent data display
- Aggregated statistics across sessions
- **1.0.0** - Initial release
- Card and Chip components
- Sheet container views

409
CasinoKit/SESSION_SYSTEM.md Normal file
View File

@ -0,0 +1,409 @@
# CasinoKit Session System
This document describes the generic session tracking system in CasinoKit that can be used by any casino game.
## Overview
The session system provides:
- **Session tracking**: Track play sessions from start to end
- **Common statistics**: Wins, losses, pushes, bets, duration
- **Game-specific statistics**: Each game can track its own custom stats
- **Session history**: Store and display completed sessions
- **Aggregated statistics**: Combine stats across all sessions
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ CasinoKit │
├─────────────────────────────────────────────────────────────────┤
│ GameSession<Stats> - Generic session with custom stats │
│ GameSpecificStats - Protocol for game stats │
│ SessionManagedGame - Protocol for game state classes │
│ AggregatedSessionStats - Combined stats across sessions │
│ SessionFormatter - Formatting utilities │
│ UI Components - Reusable session UI views │
└─────────────────────────────────────────────────────────────────┘
┌───────────────┴───────────────┐
│ │
┌───────▼───────┐ ┌───────▼───────┐
│ Blackjack │ │ Baccarat │
├───────────────┤ ├───────────────┤
│ BlackjackStats│ │ BaccaratStats │
│ (implements │ │ (implements │
│ GameSpecific) │ │ GameSpecific) │
└───────────────┘ └───────────────┘
```
## Core Types
### GameSession<Stats>
A generic session that works with any game-specific stats type.
```swift
public struct GameSession<Stats: GameSpecificStats>: Codable, Identifiable {
// Identity
let id: UUID
let gameStyle: String
// Timing
let startTime: Date
var endTime: Date?
// Balance
let startingBalance: Int
var endingBalance: Int
// Common statistics (all games track these)
var roundsPlayed: Int
var wins: Int
var losses: Int
var pushes: Int
var totalWinnings: Int
var biggestWin: Int
var biggestLoss: Int
var totalBetAmount: Int
var biggestBet: Int
// Game-specific statistics
var gameStats: Stats
// Computed
var isActive: Bool
var duration: TimeInterval
var netResult: Int
var winRate: Double
var averageBet: Int
}
```
### GameSpecificStats Protocol
Each game implements this to track game-specific statistics.
```swift
public protocol GameSpecificStats: Codable, Equatable, Sendable {
init()
var displayItems: [StatDisplayItem] { get }
}
```
**Example: Blackjack Implementation**
```swift
struct BlackjackStats: GameSpecificStats {
var blackjacks: Int = 0
var busts: Int = 0
var surrenders: Int = 0
var doubles: Int = 0
var splits: Int = 0
var insuranceTaken: Int = 0
var insuranceWon: Int = 0
var displayItems: [StatDisplayItem] {
[
StatDisplayItem(icon: "21.circle.fill", iconColor: .yellow,
label: "Blackjacks", value: "\(blackjacks)"),
StatDisplayItem(icon: "flame.fill", iconColor: .orange,
label: "Busts", value: "\(busts)"),
// ... more items
]
}
}
typealias BlackjackSession = GameSession<BlackjackStats>
```
**Example: Baccarat Implementation**
```swift
struct BaccaratStats: GameSpecificStats {
var naturals: Int = 0
var bankerWins: Int = 0
var playerWins: Int = 0
var ties: Int = 0
var playerPairs: Int = 0
var bankerPairs: Int = 0
var displayItems: [StatDisplayItem] {
[
StatDisplayItem(icon: "sparkles", iconColor: .yellow,
label: "Naturals", value: "\(naturals)"),
// ... more items
]
}
}
typealias BaccaratSession = GameSession<BaccaratStats>
```
### SessionManagedGame Protocol
Game state classes conform to this to get session management.
```swift
@MainActor
public protocol SessionManagedGame: AnyObject {
associatedtype Stats: GameSpecificStats
var currentSession: GameSession<Stats>? { get set }
var sessionHistory: [GameSession<Stats>] { get set }
var balance: Int { get set }
var startingBalance: Int { get }
var currentGameStyle: String { get }
func saveGameData()
func resetForNewSession()
}
```
**Default implementations provided:**
- `ensureActiveSession()` - Create session if needed
- `startNewSession()` - Start a fresh session
- `endCurrentSession(reason:)` - End and archive session
- `endSessionAndStartNew()` - End current and start new
- `handleSessionGameOver()` - Handle running out of money
- `recordSessionRound(...)` - Record a round result
- `aggregatedStats` - Get combined stats
- `sessions(forStyle:)` - Filter by game style
## Integration Guide
### Step 1: Create Game-Specific Stats
```swift
// In your game's Models folder
struct MyGameStats: GameSpecificStats {
var customStat1: Int = 0
var customStat2: Int = 0
init() {}
var displayItems: [StatDisplayItem] {
[
StatDisplayItem(
icon: "star.fill",
iconColor: .yellow,
label: "Custom Stat 1",
value: "\(customStat1)"
),
// Add more as needed
]
}
}
typealias MyGameSession = GameSession<MyGameStats>
```
### Step 2: Update Game Data for Persistence
```swift
struct MyGameData: PersistableGameData, SessionPersistable {
var currentSession: MyGameSession?
var sessionHistory: [MyGameSession]
var balance: Int
// ... other data
}
```
### Step 3: Conform GameState to SessionManagedGame
```swift
@Observable
@MainActor
final class GameState: SessionManagedGame {
typealias Stats = MyGameStats
var currentSession: MyGameSession?
var sessionHistory: [MyGameSession] = []
var balance: Int
var startingBalance: Int { settings.startingBalance }
var currentGameStyle: String { settings.gameStyle.rawValue }
func saveGameData() {
// Persist to CloudSyncManager
}
func resetForNewSession() {
// Reset game-specific state (reshuffle deck, clear history, etc.)
}
init() {
// Load saved data...
ensureActiveSession()
}
}
```
### Step 4: Record Rounds
```swift
// At the end of each round:
recordSessionRound(
winnings: roundWinnings,
betAmount: betAmount,
outcome: .win // or .lose, .push
) { stats in
// Update game-specific stats
if wasSpecialOutcome {
stats.customStat1 += 1
}
}
```
### Step 5: Add Aggregation Extension (Optional)
```swift
extension Array where Element == MyGameSession {
func aggregatedGameStats() -> MyGameStats {
var combined = MyGameStats()
for session in self {
combined.customStat1 += session.gameStats.customStat1
combined.customStat2 += session.gameStats.customStat2
}
return combined
}
}
```
## UI Components
CasinoKit provides reusable UI components:
### EndSessionButton
A styled button to trigger ending a session.
```swift
EndSessionButton {
state.showEndSessionConfirmation = true
}
```
### EndSessionConfirmation
A confirmation dialog showing session summary.
```swift
EndSessionConfirmation(
sessionDuration: session.duration,
netResult: session.netResult,
onConfirm: { state.endSessionAndStartNew() },
onCancel: { dismiss() }
)
```
### CurrentSessionHeader
Header showing active session with end button.
```swift
CurrentSessionHeader(
duration: session.duration,
roundsPlayed: session.roundsPlayed,
netResult: session.netResult,
onEndSession: { /* show confirmation */ }
)
```
### SessionSummaryRow
A row for displaying a session in a list.
```swift
SessionSummaryRow(
styleDisplayName: "Vegas Strip",
duration: session.duration,
roundsPlayed: session.roundsPlayed,
netResult: session.netResult,
startTime: session.startTime,
isActive: session.isActive,
endReason: session.endReason
)
```
### GameStatRow
Display a single stat item from `displayItems`.
```swift
ForEach(session.gameStats.displayItems) { item in
GameStatRow(item: item)
}
```
## SessionFormatter
Utility for formatting session data:
```swift
SessionFormatter.formatDuration(seconds) // "02h 15min"
SessionFormatter.formatMoney(amount) // "$500" or "-$250"
SessionFormatter.formatPercent(value) // "65.5%"
SessionFormatter.formatSessionDate(date) // "Dec 29, 2024, 10:30 AM"
SessionFormatter.formatRelativeDate(date) // "2h ago"
```
## Session Flow
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ App Launch │────▶│ Load Data │────▶│ Ensure │
│ │ │ │ │ Session │
└─────────────┘ └─────────────┘ └──────┬──────┘
┌──────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ ACTIVE SESSION │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Bet │────▶│ Play │────▶│ Record │──┐ │
│ │ │ │ Round │ │ Round │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │ │
│ ▲ │ │
│ └───────────────────────────────────────┘ │
└────────────────────────┬───────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ End Session │ │ Game Over │ │ App Close │
│ (Manual) │ │ (Broke) │ │ (Auto-save) │
└──────┬──────┘ └──────┬──────┘ └─────────────┘
│ │
└────────┬────────┘
┌─────────────────────────────────────────────────────┐
│ Session archived to history │
│ New session can be started │
└─────────────────────────────────────────────────────┘
```
## Best Practices
1. **Always call `ensureActiveSession()` on init** - Guarantees a session exists.
2. **Update balance in session after each round** - The `recordSessionRound` method handles this.
3. **Implement `resetForNewSession()` properly** - Clear round history, reshuffle decks, reset UI state.
4. **Use type aliases for convenience** - `typealias BlackjackSession = GameSession<BlackjackStats>`
5. **Provide aggregation extensions** - For combining game-specific stats across sessions.
6. **Use `StatDisplayItem` for consistent UI** - Makes stats display automatic in UI components.
## Data Storage
Session data is stored via `CloudSyncManager` which handles:
- Local persistence to UserDefaults
- iCloud sync when available
- Conflict resolution using `lastModified` timestamps
The `SessionPersistable` protocol extends `PersistableGameData` to add:
```swift
public protocol SessionPersistable {
associatedtype Stats: GameSpecificStats
var currentSession: GameSession<Stats>? { get set }
var sessionHistory: [GameSession<Stats>] { get set }
}
```

View File

@ -15,6 +15,8 @@
// - TableLimits
// - OnboardingState
// - TooltipManager, TooltipConfig
// - GameSettingsProtocol (shared settings interface)
// - SettingsKeys, SettingsDefaults (persistence helpers)
// MARK: - Views
// - CardView, CardFrontView, CardBackView, CardPlaceholderView
@ -71,6 +73,7 @@
// - SelectionIndicator (checkmark circle)
// - BadgePill (capsule badge for values)
// MARK: - Branding
// - AppIconView, AppIconConfig
// - LaunchScreenView, LaunchScreenConfig, StaticLaunchScreenView
@ -91,6 +94,35 @@
// - CloudSyncManager
// - PersistableGameData (protocol)
// MARK: - Sessions
// - GameSession<Stats> (generic session with game-specific stats)
// - GameSpecificStats (protocol for game-specific statistics)
// - SessionManagedGame (protocol for games with session management)
// - CasinoGameState (protocol extending SessionManagedGame with shared behaviors)
// - SessionEndReason (.manualEnd, .brokeOut)
// - RoundOutcome (.win, .lose, .push)
// - AggregatedSessionStats (combined stats from multiple sessions)
// - StatDisplayItem (for displaying game-specific stats)
// - SessionFormatter (formatting utilities)
// - EndSessionButton, EndSessionConfirmation (UI components)
// - CurrentSessionHeader, SessionSummaryRow (UI components)
// - GameStatRow (display a stat item)
// MARK: - Statistics Components
// - StatisticsTab (enum: current, global, history)
// - StatisticsTabSelector (tab picker for stats views)
// - StatColumn (vertical stat display)
// - OutcomeCircle (win/loss/push circles)
// - StatRow (horizontal stat with icon)
// - ChipStatRow (stat with colored circle icon)
// - NoActiveSessionView (placeholder)
// - EmptyHistoryView (placeholder)
// - SessionPerformanceSection (winning/losing sessions)
// - ChipsStatsSection (betting stats)
// - BalanceSection (starting/ending balance)
// - SessionDetailHeader (date, result, win rate)
// - DeleteSessionButton (destructive delete button)
// MARK: - Debug
// - debugBorder(_:color:label:) View modifier

View File

@ -70,26 +70,8 @@ public enum Rank: Int, CaseIterable, Identifiable, Sendable {
}
}
/// The baccarat point value (Ace = 1, 2-9 = face value, 10/J/Q/K = 0).
public var baccaratValue: Int {
switch self {
case .ace: return 1
case .two, .three, .four, .five, .six, .seven, .eight, .nine:
return rawValue
case .ten, .jack, .queen, .king:
return 0
}
}
/// The blackjack value (Ace = 1 or 11, 2-10 = face value, J/Q/K = 10).
/// Note: Ace flexibility (1 or 11) should be handled by game logic.
public var blackjackValue: Int {
switch self {
case .ace: return 11 // Game logic should handle soft/hard hands
case .jack, .queen, .king: return 10
default: return rawValue
}
}
// Game-specific value properties (baccaratValue, blackjackValue) should be
// defined as extensions in the respective game apps.
/// Accessibility name for VoiceOver.
public var accessibilityName: String {
@ -123,15 +105,7 @@ public struct Card: Identifiable, Equatable, Sendable {
self.rank = rank
}
/// The baccarat point value of this card.
public var baccaratValue: Int {
rank.baccaratValue
}
/// The blackjack value of this card.
public var blackjackValue: Int {
rank.blackjackValue
}
// Game-specific value properties should be defined as extensions in game apps.
/// Display string showing rank and suit together.
public var display: String {

View File

@ -0,0 +1,176 @@
//
// CasinoGameState.swift
// CasinoKit
//
// Protocol defining common state management patterns for casino games.
// Extends SessionManagedGame with additional shared functionality.
//
import Foundation
/// Protocol for casino game state classes that manage game flow,
/// session tracking, and common behaviors.
///
/// This extends `SessionManagedGame` to add additional requirements
/// that are common across all casino games in this app.
@MainActor
public protocol CasinoGameState: SessionManagedGame {
/// The settings object conforming to GameSettingsProtocol.
associatedtype GameSettingsType: GameSettingsProtocol
/// Game-specific settings.
var settings: GameSettingsType { get }
/// The shared sound manager.
var soundManager: SoundManager { get }
/// Onboarding state for first-time users.
var onboarding: OnboardingState { get }
/// Resets the game to starting conditions (balance, cards, etc.)
/// without clearing session history.
func resetGame()
/// Clears all data including session history.
func clearAllData()
/// Aggregated statistics from all sessions.
var aggregatedStats: AggregatedSessionStats { get }
// MARK: - Chip Selection
/// The minimum bet level across all bet types.
/// Used by ChipSelectorView to determine if chips should be enabled.
/// Returns the smallest bet so chips stay enabled if ANY bet type can accept more.
///
/// Games with multiple bet types (main bet, side bets) should return the minimum
/// bet amount across all placeable bet types, ensuring the chip selector remains
/// responsive as long as at least one bet type has room for more chips.
var minBetForChipSelector: Int { get }
}
// MARK: - Default Implementations
public extension CasinoGameState {
/// Default implementation uses shared SoundManager.
var soundManager: SoundManager { SoundManager.shared }
/// Default aggregated stats computed from session history.
var aggregatedStats: AggregatedSessionStats {
sessionHistory.aggregatedStats()
}
/// Play a sound effect if enabled.
func playSound(_ sound: GameSound) {
soundManager.play(sound)
}
/// Play light haptic feedback if enabled.
func hapticLight() {
soundManager.hapticLight()
}
/// Play medium haptic feedback if enabled.
func hapticMedium() {
soundManager.hapticMedium()
}
/// Play success haptic feedback if enabled.
func hapticSuccess() {
soundManager.hapticSuccess()
}
/// Play error haptic feedback if enabled.
func hapticError() {
soundManager.hapticError()
}
/// Convenience: Check if we should play sounds.
var isSoundEnabled: Bool {
settings.soundEnabled && soundManager.soundEnabled
}
/// Convenience: Check if we should play haptics.
var isHapticsEnabled: Bool {
settings.hapticsEnabled && soundManager.hapticsEnabled
}
}
// MARK: - Balance Management Extensions
public extension CasinoGameState {
/// Whether the player is out of chips (balance is 0 or below minimum bet).
var isBroke: Bool {
balance < settings.minBet
}
/// Whether the player can afford a bet of the given amount.
func canAffordBet(_ amount: Int) -> Bool {
balance >= amount
}
/// The maximum bet the player can currently place.
var maxAffordableBet: Int {
min(balance, settings.maxBet)
}
}
// MARK: - Game Flow Extensions
public extension CasinoGameState {
/// Ends the current session due to running out of chips.
func endSessionDueToBroke() {
if currentSession != nil {
endCurrentSession(reason: .brokeOut)
}
}
/// Checks if the player is broke and handles session ending if needed.
/// Returns true if the player is broke.
@discardableResult
func checkBrokeStatus() -> Bool {
if isBroke {
endSessionDueToBroke()
return true
}
return false
}
}
// MARK: - Game Reset Extensions
public extension CasinoGameState {
/// Default implementation of resetGame that properly handles session ending.
/// Games can override this but should call the helper methods in the correct order.
///
/// The correct order is:
/// 1. End current session (captures actual ending balance)
/// 2. Reset balance to starting balance
/// 3. Reset game-specific state (via resetForNewSession)
/// 4. Start new session with fresh balance
/// 5. Save data
func performResetGame() {
// 1. End current session FIRST while balance still shows actual state
// Use .brokeOut if balance is 0, otherwise .manualEnd
if currentSession != nil {
let reason: SessionEndReason = balance <= 0 ? .brokeOut : .manualEnd
endCurrentSession(reason: reason)
}
// 2. Reset balance to starting balance
balance = startingBalance
// 3. Let game reset its specific state (reshuffle deck, clear history, etc.)
resetForNewSession()
// 4. Create new session with fresh balance
currentSession = GameSession<Stats>(
gameStyle: currentGameStyle,
startingBalance: balance
)
// 5. Save
saveGameData()
}
}

View File

@ -0,0 +1,106 @@
//
// GameSettingsProtocol.swift
// CasinoKit
//
// Protocol defining common settings shared across all casino games.
// Each game can conform to this protocol and add game-specific settings.
//
import Foundation
/// Protocol defining the minimum required settings for any casino game.
/// Games conforming to this protocol share common UI settings and behaviors.
@MainActor
public protocol GameSettingsProtocol: AnyObject {
// MARK: - Table Configuration
/// The table limits preset (affects min/max bets).
var tableLimits: TableLimits { get set }
/// Minimum bet amount (derived from tableLimits).
var minBet: Int { get }
/// Maximum bet amount (derived from tableLimits).
var maxBet: Int { get }
/// Starting balance for new sessions.
var startingBalance: Int { get set }
// MARK: - Animation Settings
/// Whether to show card dealing and other animations.
var showAnimations: Bool { get set }
/// Speed multiplier for dealing (1.0 = normal).
var dealingSpeed: Double { get set }
// MARK: - Display Settings
/// Whether to show hints and recommendations.
var showHints: Bool { get set }
// MARK: - Sound Settings
/// Whether sound effects are enabled.
var soundEnabled: Bool { get set }
/// Whether haptic feedback is enabled.
var hapticsEnabled: Bool { get set }
/// Volume level for sound effects (0.0 to 1.0).
var soundVolume: Float { get set }
// MARK: - Persistence
/// Saves settings to persistent storage.
func save()
/// Loads settings from persistent storage.
func load()
/// Resets all settings to defaults.
func resetToDefaults()
}
// MARK: - Default Implementations
public extension GameSettingsProtocol {
/// Minimum bet derived from table limits.
var minBet: Int {
tableLimits.minBet
}
/// Maximum bet derived from table limits.
var maxBet: Int {
tableLimits.maxBet
}
}
// MARK: - Settings Persistence Helpers
/// Common settings keys for persistence.
public enum SettingsKeys {
public static let tableLimits = "settings.tableLimits"
public static let startingBalance = "settings.startingBalance"
public static let showAnimations = "settings.showAnimations"
public static let dealingSpeed = "settings.dealingSpeed"
public static let showHints = "settings.showHints"
public static let soundEnabled = "settings.soundEnabled"
public static let hapticsEnabled = "settings.hapticsEnabled"
public static let soundVolume = "settings.soundVolume"
}
// MARK: - Settings Defaults
/// Default values for common settings.
public enum SettingsDefaults {
public static let tableLimits: TableLimits = .casual
public static let startingBalance: Int = 1_000
public static let showAnimations: Bool = true
public static let dealingSpeed: Double = 1.0
public static let showHints: Bool = true
public static let soundEnabled: Bool = true
public static let hapticsEnabled: Bool = true
public static let soundVolume: Float = 1.0
}

View File

@ -26,6 +26,10 @@ public final class OnboardingState {
/// Set of hint keys that have been shown to the user.
public var hintsShown: Set<String> = []
/// Hint keys registered by the app for automatic skipping.
/// When the user skips onboarding, all registered hints are marked as shown.
private var registeredHintKeys: Set<String> = []
// MARK: - Initialization
private let persistenceKey: String
@ -35,6 +39,18 @@ public final class OnboardingState {
load()
}
/// Registers hint keys that should be marked as shown when skipping onboarding.
/// Call this once during app setup with all hint keys used by the game.
public func registerHintKeys(_ keys: Set<String>) {
registeredHintKeys = keys
}
/// Registers hint keys that should be marked as shown when skipping onboarding.
/// Call this once during app setup with all hint keys used by the game.
public func registerHintKeys(_ keys: String...) {
registeredHintKeys = Set(keys)
}
// MARK: - Hint Management
/// Marks a hint as shown and persists the state.
@ -55,6 +71,18 @@ public final class OnboardingState {
save()
}
/// Skips onboarding entirely - marks all registered hints as shown and completes welcome.
/// Use this when the user dismisses the welcome sheet without choosing tutorial mode
/// (e.g., swiping down to dismiss, or tapping "Start Playing").
public func skipOnboarding() {
for key in registeredHintKeys {
hintsShown.insert(key)
}
hasLaunchedBefore = true
hasCompletedWelcome = true
save()
}
/// Enables tutorial mode (shows all hints again).
public func startTutorialMode() {
isTutorialMode = true

View File

@ -0,0 +1,328 @@
//
// GameSessionProtocol.swift
// CasinoKit
//
// Generic protocols for game sessions that work with any casino game.
// Each game (Blackjack, Baccarat, etc.) provides their own implementation.
//
import Foundation
import SwiftUI
// MARK: - Game-Specific Stats Protocol
/// Protocol for game-specific statistics that each game implements.
/// Example: Blackjack tracks blackjacks, busts, surrenders.
/// Baccarat tracks naturals, banker wins, player wins.
public protocol GameSpecificStats: Codable, Equatable, Sendable {
/// Creates empty stats.
init()
/// A list of stat items to display in the UI.
/// Each game returns its specific stats in a displayable format.
var displayItems: [StatDisplayItem] { get }
}
/// A single stat item for display in the UI.
public struct StatDisplayItem: Identifiable, Sendable {
public let id = UUID()
public let icon: String
public let iconColor: Color
public let label: String
public let value: String
public let valueColor: Color
public init(
icon: String,
iconColor: Color = .white,
label: String,
value: String,
valueColor: Color = .white
) {
self.icon = icon
self.iconColor = iconColor
self.label = label
self.value = value
self.valueColor = valueColor
}
}
// MARK: - Round Outcome
/// Outcome of a single round - common to all casino games.
public enum RoundOutcome: String, Codable, Sendable {
case win
case lose
case push
}
// MARK: - Session End Reason
/// Reason why a session ended.
public enum SessionEndReason: String, Codable, Sendable {
case manualEnd = "ended" // Player chose to end session
case brokeOut = "broke" // Ran out of money
}
// MARK: - Game Session
/// A generic game session that works with any casino game.
/// The Stats type parameter allows each game to track game-specific statistics.
public struct GameSession<Stats: GameSpecificStats>: Codable, Identifiable, Equatable, Sendable {
// MARK: - Identity
/// Unique identifier for this session.
public let id: UUID
/// The game style/variant used during this session (e.g., "vegas", "european").
/// This is mutable to stay in sync with current settings.
public var gameStyle: String
// MARK: - Timing
/// When the session started.
public let startTime: Date
/// When the session ended (nil if still active).
public var endTime: Date?
// MARK: - Balance
/// Balance at the start of the session.
public let startingBalance: Int
/// Current/final balance for this session.
public var endingBalance: Int
// MARK: - Common Statistics (all games track these)
/// Number of rounds played in this session.
public var roundsPlayed: Int = 0
/// Number of winning rounds.
public var wins: Int = 0
/// Number of losing rounds.
public var losses: Int = 0
/// Number of pushed/tied rounds.
public var pushes: Int = 0
// MARK: - Financial Statistics
/// Net winnings/losses for this session.
public var totalWinnings: Int = 0
/// Biggest single round win.
public var biggestWin: Int = 0
/// Biggest single round loss (stored as negative).
public var biggestLoss: Int = 0
/// Total amount bet across all rounds.
public var totalBetAmount: Int = 0
/// Largest single bet placed.
public var biggestBet: Int = 0
// MARK: - Game-Specific Statistics
/// Game-specific statistics (Blackjack-specific, Baccarat-specific, etc.).
public var gameStats: Stats
// MARK: - Computed Properties
/// Whether this session is still active.
public var isActive: Bool {
endTime == nil
}
/// Duration of the session in seconds.
public var duration: TimeInterval {
let end = endTime ?? Date()
return end.timeIntervalSince(startTime)
}
/// Net result (ending balance - starting balance).
public var netResult: Int {
endingBalance - startingBalance
}
/// Win rate as a percentage (0-100).
public var winRate: Double {
guard roundsPlayed > 0 else { return 0 }
return Double(wins) / Double(roundsPlayed) * 100
}
/// Average bet per round.
public var averageBet: Int {
guard roundsPlayed > 0 else { return 0 }
return totalBetAmount / roundsPlayed
}
/// How the session ended.
public var endReason: SessionEndReason? {
guard !isActive else { return nil }
if endingBalance == 0 {
return .brokeOut
}
return .manualEnd
}
// MARK: - Initialization
/// Creates a new active session.
public init(gameStyle: String, startingBalance: Int) {
self.id = UUID()
self.gameStyle = gameStyle
self.startTime = Date()
self.endTime = nil
self.startingBalance = startingBalance
self.endingBalance = startingBalance
self.gameStats = Stats()
}
// MARK: - Recording
/// Records a round with the given outcome and updates stats.
public mutating func recordRound(
winnings: Int,
betAmount: Int,
outcome: RoundOutcome
) {
roundsPlayed += 1
totalWinnings += winnings
totalBetAmount += betAmount
if winnings > biggestWin {
biggestWin = winnings
}
if winnings < biggestLoss {
biggestLoss = winnings
}
if betAmount > biggestBet {
biggestBet = betAmount
}
switch outcome {
case .win:
wins += 1
case .lose:
losses += 1
case .push:
pushes += 1
}
}
/// Ends the session with the final balance.
public mutating func end(withBalance balance: Int) {
endTime = Date()
endingBalance = balance
}
/// Updates the ending balance (call after each round).
public mutating func updateBalance(_ balance: Int) {
endingBalance = balance
}
}
// MARK: - Aggregated Stats
/// Aggregated statistics across multiple sessions.
/// Works with any game type.
public struct AggregatedSessionStats: Sendable {
public var totalSessions: Int = 0
public var winningSessions: Int = 0
public var losingSessions: Int = 0
public var totalRoundsPlayed: Int = 0
public var totalWins: Int = 0
public var totalLosses: Int = 0
public var totalPushes: Int = 0
public var totalWinnings: Int = 0
public var biggestWin: Int = 0
public var biggestLoss: Int = 0
public var bestSession: Int = 0
public var worstSession: Int = 0
public var totalPlayTime: TimeInterval = 0
public var totalBetAmount: Int = 0
public var biggestBet: Int = 0
public var winRate: Double {
guard totalRoundsPlayed > 0 else { return 0 }
return Double(totalWins) / Double(totalRoundsPlayed) * 100
}
public var averageBet: Int {
guard totalRoundsPlayed > 0 else { return 0 }
return totalBetAmount / totalRoundsPlayed
}
public var sessionWinRate: Double {
guard totalSessions > 0 else { return 0 }
return Double(winningSessions) / Double(totalSessions) * 100
}
public init() {}
}
// MARK: - Array Extension for Aggregation
extension Array {
/// Aggregates sessions into combined statistics.
public func aggregatedStats<Stats: GameSpecificStats>() -> AggregatedSessionStats
where Element == GameSession<Stats> {
var stats = AggregatedSessionStats()
for session in self {
stats.totalSessions += 1
stats.totalRoundsPlayed += session.roundsPlayed
stats.totalWins += session.wins
stats.totalLosses += session.losses
stats.totalPushes += session.pushes
stats.totalWinnings += session.totalWinnings
stats.totalPlayTime += session.duration
stats.totalBetAmount += session.totalBetAmount
if session.biggestWin > stats.biggestWin {
stats.biggestWin = session.biggestWin
}
if session.biggestLoss < stats.biggestLoss {
stats.biggestLoss = session.biggestLoss
}
if session.biggestBet > stats.biggestBet {
stats.biggestBet = session.biggestBet
}
if session.netResult > 0 {
stats.winningSessions += 1
if session.netResult > stats.bestSession {
stats.bestSession = session.netResult
}
} else if session.netResult < 0 {
stats.losingSessions += 1
if session.netResult < stats.worstSession {
stats.worstSession = session.netResult
}
}
}
return stats
}
/// Aggregates game-specific stats from all sessions.
/// Games should provide their own aggregation logic.
public func aggregatedGameStats<Stats: GameSpecificStats>() -> Stats
where Element == GameSession<Stats> {
var combined = Stats()
// Game-specific stats need custom aggregation - this returns the last session's stats
// Each game should implement their own aggregation extension
if let last = self.last {
combined = last.gameStats
}
return combined
}
}

View File

@ -0,0 +1,287 @@
//
// SessionManager.swift
// CasinoKit
//
// Protocol and utilities for managing game sessions.
// Games conform to SessionManagedGame to get session management for free.
//
import Foundation
import SwiftUI
// MARK: - Session Managed Game Protocol
/// Protocol for game state classes that use session management.
/// Conforming to this protocol gives you automatic session tracking.
///
/// Example usage in GameState:
/// ```swift
/// @Observable
/// final class GameState: SessionManagedGame {
/// typealias Stats = BlackjackStats
/// var currentSession: GameSession<BlackjackStats>?
/// var sessionHistory: [GameSession<BlackjackStats>] = []
/// // ... rest of implementation
/// }
/// ```
@MainActor
public protocol SessionManagedGame: AnyObject {
/// The game-specific stats type.
associatedtype Stats: GameSpecificStats
/// The currently active session (nil if no session).
var currentSession: GameSession<Stats>? { get set }
/// History of completed sessions.
var sessionHistory: [GameSession<Stats>] { get set }
/// Current player balance.
var balance: Int { get set }
/// Starting balance for new sessions (from settings).
var startingBalance: Int { get }
/// Current game style identifier (e.g., "vegas", "european").
var currentGameStyle: String { get }
/// Whether the end session confirmation dialog should be shown.
/// Games should bind this to their confirmation dialog.
var showEndSessionConfirmation: Bool { get set }
/// Called to persist game data after session changes.
func saveGameData()
/// Called when starting a new session to reset game-specific state.
func resetForNewSession()
/// Called before a session is deleted. Override to clean up associated data.
/// Default implementation does nothing.
func onWillDeleteSession(id: UUID)
/// Called before all session history is deleted. Override to clean up associated data.
/// Default implementation does nothing.
func onWillDeleteAllSessions()
}
// MARK: - Default Session Management Implementation
extension SessionManagedGame {
/// Ensures there's an active session, creating one if needed.
public func ensureActiveSession() {
if currentSession == nil {
startNewSession()
}
}
/// Starts a new session.
///
/// This uses the current `balance` as the session's starting balance. If you need to
/// start fresh with the default starting balance from settings, call `endSessionAndStartNew()`
/// instead, which properly resets the balance before creating the new session.
public func startNewSession() {
// End current session if exists
if currentSession != nil {
endCurrentSession(reason: .manualEnd)
}
// If balance is 0 or below minimum bet, reset to starting balance from settings.
// This handles the case where the player went broke and the app was restarted
// before they clicked "Play Again".
if balance <= 0 {
balance = startingBalance
}
// Create new session with current settings
currentSession = GameSession<Stats>(
gameStyle: currentGameStyle,
startingBalance: balance
)
saveGameData()
}
/// Ends the current session and adds it to history.
public func endCurrentSession(reason: SessionEndReason = .manualEnd) {
guard var session = currentSession else { return }
session.end(withBalance: balance)
// Add to history (most recent first)
sessionHistory.insert(session, at: 0)
currentSession = nil
saveGameData()
}
/// Ends the current session and starts a fresh one.
public func endSessionAndStartNew() {
endCurrentSession(reason: .manualEnd)
// Reset to starting balance
balance = startingBalance
// Let game reset its specific state
resetForNewSession()
// Start fresh session
startNewSession()
}
/// Called when player runs out of money.
public func handleSessionGameOver() {
endCurrentSession(reason: .brokeOut)
}
/// Records a round result in the current session.
/// Call this at the end of each round with the outcome.
public func recordSessionRound(
winnings: Int,
betAmount: Int,
outcome: RoundOutcome,
updateGameStats: ((inout Stats) -> Void)? = nil
) {
guard var session = currentSession else { return }
// Record common stats
session.recordRound(
winnings: winnings,
betAmount: betAmount,
outcome: outcome
)
// Let game update its specific stats
if let update = updateGameStats {
update(&session.gameStats)
}
// Update balance
session.updateBalance(balance)
currentSession = session
saveGameData()
}
// MARK: - Computed Helpers
/// Whether there's an active session.
public var hasActiveSession: Bool {
currentSession != nil
}
/// All sessions including current.
public var allSessions: [GameSession<Stats>] {
var sessions: [GameSession<Stats>] = []
if let current = currentSession {
sessions.append(current)
}
sessions.append(contentsOf: sessionHistory)
return sessions
}
/// Aggregated stats from all sessions.
public var aggregatedStats: AggregatedSessionStats {
allSessions.aggregatedStats()
}
/// Sessions filtered by game style.
public func sessions(forStyle style: String) -> [GameSession<Stats>] {
allSessions.filter { $0.gameStyle == style }
}
/// Aggregated stats for a specific style.
public func aggregatedStats(forStyle style: String) -> AggregatedSessionStats {
sessions(forStyle: style).aggregatedStats()
}
// MARK: - Session History Management
/// Default implementation - does nothing. Override to clean up session-specific data.
public func onWillDeleteSession(id: UUID) {
// Override in game to clean up associated data (e.g., round histories)
}
/// Default implementation - does nothing. Override to clean up all session data.
public func onWillDeleteAllSessions() {
// Override in game to clean up associated data (e.g., round histories)
}
/// Deletes a session from history by ID.
public func deleteSession(id: UUID) {
onWillDeleteSession(id: id)
sessionHistory.removeAll { $0.id == id }
saveGameData()
}
/// Deletes all session history.
public func deleteAllSessionHistory() {
onWillDeleteAllSessions()
sessionHistory.removeAll()
saveGameData()
}
}
// MARK: - Session Formatter
/// Utility for formatting session data for display.
public enum SessionFormatter {
/// Formats a duration as "XXh XXmin".
public static func formatDuration(_ seconds: TimeInterval) -> String {
let hours = Int(seconds) / 3600
let minutes = (Int(seconds) % 3600) / 60
return String(format: "%02dh %02dmin", hours, minutes)
}
/// Formats a duration as "X hours, Y minutes" for accessibility.
public static func formatDurationAccessible(_ seconds: TimeInterval) -> String {
let hours = Int(seconds) / 3600
let minutes = (Int(seconds) % 3600) / 60
if hours > 0 {
return "\(hours) hours, \(minutes) minutes"
} else {
return "\(minutes) minutes"
}
}
/// Formats money with sign.
public static func formatMoney(_ amount: Int) -> String {
if amount >= 0 {
return "$\(amount)"
} else {
return "-$\(abs(amount))"
}
}
/// Formats a date as relative time (e.g., "2 hours ago").
public static func formatRelativeDate(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: date, relativeTo: Date())
}
/// Formats a date for session display.
public static func formatSessionDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.string(from: date)
}
/// Formats a percentage.
public static func formatPercent(_ value: Double) -> String {
value.formatted(.number.precision(.fractionLength(1))) + "%"
}
}
// MARK: - Session Data Protocol
/// Protocol for game data structs that store sessions.
/// Use this to define your PersistableGameData with session support.
public protocol SessionPersistable {
associatedtype Stats: GameSpecificStats
var currentSession: GameSession<Stats>? { get set }
var sessionHistory: [GameSession<Stats>] { get set }
}

View File

@ -217,6 +217,10 @@
}
}
},
"$%lld" : {
"comment" : "A label displaying the starting balance for a session. The value inside the parentheses is replaced with the actual starting balance.",
"isCommentAutoGenerated" : true
},
"$%lld bet" : {
"comment" : "A value describing the bet amount in the accessibility label. The argument is the bet amount.",
"extractionState" : "stale",
@ -334,6 +338,10 @@
}
}
},
"ACTIVE" : {
"comment" : "A status label indicating that a session is currently active.",
"isCommentAutoGenerated" : true
},
"Add $%lld more to meet minimum" : {
"comment" : "Hint shown when bet is below table minimum. The argument is the dollar amount needed.",
"localizations" : {
@ -427,6 +435,10 @@
}
}
},
"Average bet" : {
"comment" : "Label for the average bet value in the ChipsStatsSection.",
"isCommentAutoGenerated" : true
},
"Balance" : {
"localizations" : {
"en" : {
@ -449,6 +461,14 @@
}
}
},
"Best gain" : {
"comment" : "Label in the \"Chips Stats Section\" view for the user's best gain.",
"isCommentAutoGenerated" : true
},
"Best session" : {
"comment" : "A label describing the best session amount in the statistics view.",
"isCommentAutoGenerated" : true
},
"Betting disabled" : {
"comment" : "A hint that appears when a betting zone is disabled.",
"extractionState" : "stale",
@ -478,6 +498,18 @@
"comment" : "The accessibility label for the betting hint view.",
"isCommentAutoGenerated" : true
},
"Biggest bet" : {
"comment" : "Label for the \"Biggest bet\" statistic in the ChipsStatsSection.",
"isCommentAutoGenerated" : true
},
"Bust" : {
"comment" : "A string describing when a player busts out of a game.",
"isCommentAutoGenerated" : true
},
"Cancel" : {
"comment" : "A button label that cancels an action.",
"isCommentAutoGenerated" : true
},
"Card face down" : {
"localizations" : {
"en" : {
@ -637,6 +669,10 @@
}
}
},
"Completed sessions will appear here." : {
"comment" : "A description of what to expect to see in the completed sessions section of the statistics view.",
"isCommentAutoGenerated" : true
},
"Contact Us" : {
"localizations" : {
"en" : {
@ -659,6 +695,14 @@
}
}
},
"Current" : {
"comment" : "Title of the \"Current\" tab in the statistics view.",
"isCommentAutoGenerated" : true
},
"Current Session" : {
"comment" : "A label for the header of the current session section.",
"isCommentAutoGenerated" : true
},
"Data Storage" : {
"comment" : "Title of a section in the Privacy Policy View that discusses how game data is stored.",
"isCommentAutoGenerated" : true,
@ -732,6 +776,10 @@
}
}
},
"Delete Session" : {
"comment" : "A button label that deletes a session.",
"isCommentAutoGenerated" : true
},
"Diamonds" : {
"localizations" : {
"en" : {
@ -823,6 +871,10 @@
}
}
},
"Duration" : {
"comment" : "A label displayed next to the duration of a session",
"isCommentAutoGenerated" : true
},
"Eight" : {
"localizations" : {
"en" : {
@ -867,6 +919,26 @@
}
}
},
"End Session" : {
"comment" : "A button label that says \"End Session\".",
"isCommentAutoGenerated" : true
},
"End Session?" : {
"comment" : "A title for the confirmation dialog that asks if the user is sure they want to end their session.",
"isCommentAutoGenerated" : true
},
"Ended" : {
"comment" : "A label indicating that a session has ended.",
"isCommentAutoGenerated" : true
},
"Ended manually" : {
"comment" : "A description of how a session ended manually.",
"isCommentAutoGenerated" : true
},
"Ending balance" : {
"comment" : "A label describing the user's balance at the end of a session.",
"isCommentAutoGenerated" : true
},
"Exclusive VIP room" : {
"localizations" : {
"en" : {
@ -1027,8 +1099,16 @@
}
}
},
"Global" : {
"comment" : "Title for the \"Global\" tab in the statistics view.",
"isCommentAutoGenerated" : true
},
"Got it" : {
},
"Hands" : {
"comment" : "Label for the number of hands played in the current session.",
"isCommentAutoGenerated" : true
},
"Hearts" : {
"localizations" : {
@ -1096,6 +1176,10 @@
}
}
},
"History" : {
"comment" : "Title of the History tab in the Statistics sheet.",
"isCommentAutoGenerated" : true
},
"iCloud Sync" : {
"localizations" : {
"en" : {
@ -1338,6 +1422,10 @@
}
}
},
"Losing sessions" : {
"comment" : "A label for the number of sessions a user has lost.",
"isCommentAutoGenerated" : true
},
"Low Stakes" : {
"localizations" : {
"en" : {
@ -1425,6 +1513,13 @@
}
}
}
},
"Net" : {
"comment" : "Label for the net result in the current session header.",
"isCommentAutoGenerated" : true
},
"Net Result" : {
},
"New Round" : {
"comment" : "A button label that initiates a new round of a casino game.",
@ -1452,6 +1547,10 @@
}
}
},
"No Active Session" : {
"comment" : "A label describing a state where there is no active session.",
"isCommentAutoGenerated" : true
},
"No bet" : {
"comment" : "A description of a zone with no active bet.",
"extractionState" : "stale",
@ -1476,6 +1575,9 @@
}
}
}
},
"No Session History" : {
},
"Our apps do not integrate with third-party services that collect user data. We do not share any information with third parties." : {
"localizations" : {
@ -1631,6 +1733,10 @@
}
}
},
"Ran out of chips" : {
"comment" : "A description of why a casino session might have ended with a negative net result.",
"isCommentAutoGenerated" : true
},
"Regular casino table" : {
"localizations" : {
"en" : {
@ -1881,6 +1987,14 @@
},
"Start Playing" : {
},
"Start playing to begin tracking your session." : {
"comment" : "A description below the title of the view, explaining that users can start playing to track their sessions.",
"isCommentAutoGenerated" : true
},
"Starting balance" : {
"comment" : "A label describing the starting balance of a session.",
"isCommentAutoGenerated" : true
},
"Statistics" : {
"localizations" : {
@ -1996,6 +2110,14 @@
"comment" : "A label displayed alongside the total winnings in the result banner.",
"isCommentAutoGenerated" : true
},
"Total bet" : {
"comment" : "Label for the total amount bet in a statistics view.",
"isCommentAutoGenerated" : true
},
"Total gain" : {
"comment" : "Label for the \"Total gain\" row in the chips statistics section.",
"isCommentAutoGenerated" : true
},
"Total loss: %lld" : {
"comment" : "A string that describes the total loss in a casino game. The argument is the absolute value of the total loss, formatted in U.S. Dollars.",
"isCommentAutoGenerated" : true
@ -2206,6 +2328,22 @@
}
}
},
"win rate" : {
"comment" : "A label describing the win rate of a session.",
"isCommentAutoGenerated" : true
},
"Winning sessions" : {
"comment" : "A label describing the number of sessions the user has won.",
"isCommentAutoGenerated" : true
},
"Worst loss" : {
"comment" : "Label for the worst loss in the statistics section.",
"isCommentAutoGenerated" : true
},
"Worst session" : {
"comment" : "A label for the worst session amount in the statistics view.",
"isCommentAutoGenerated" : true
},
"You can disable iCloud sync at any time in the app settings" : {
"comment" : "Text in the Privacy Policy View explaining how to disable iCloud sync in the app settings.",
"isCommentAutoGenerated" : true,
@ -2273,6 +2411,10 @@
}
}
}
},
"Your session will be saved to history and a new session will start." : {
"comment" : "A piece of information displayed in the confirmation dialog, explaining that the user's session will be saved and a new one will begin.",
"isCommentAutoGenerated" : true
}
},
"version" : "1.1"

View File

@ -111,6 +111,26 @@ public enum CasinoDesign {
public static let staggerDelay1: Double = 0.1
public static let staggerDelay2: Double = 0.25
public static let staggerDelay3: Double = 0.4
/// Creates an easeOut animation for card dealing.
/// Cards decelerate as they arrive, simulating frictionmore realistic than spring bounce.
/// - Parameter speed: Speed multiplier (1.0 = normal, lower = faster)
public static func cardDeal(speed: Double = 1.0) -> SwiftUI.Animation {
.easeOut(duration: springDuration * speed)
}
}
// MARK: - Dealing Speed
/// Speed multipliers for card dealing animations.
/// Lower values = faster dealing. Multiplied by `Animation.springDuration`.
public enum DealingSpeed {
/// Fast dealing (snappy, quick deal)
public static let fast: Double = 0.4
/// Normal dealing speed
public static let normal: Double = 0.6
/// Slow dealing (relaxed pace)
public static let slow: Double = 0.8
}
// MARK: - Sizes

View File

@ -7,7 +7,7 @@
import SwiftUI
/// A circular badge showing a numeric value.
/// A capsule badge showing a numeric value.
public struct ValueBadge: View {
/// The value to display.
public let value: Int
@ -21,7 +21,8 @@ public struct ValueBadge: View {
// MARK: - Scaled Font Sizes (Dynamic Type)
@ScaledMetric(relativeTo: .headline) private var valueFontSize: CGFloat = 15
@ScaledMetric(relativeTo: .headline) private var badgeSize: CGFloat = CasinoDesign.Size.valueBadge
@ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge
@ScaledMetric(relativeTo: .headline) private var badgePadding: CGFloat = 8
/// Creates a value badge.
/// - Parameters:
@ -34,17 +35,20 @@ public struct ValueBadge: View {
self.size = size
}
private var displaySize: CGFloat {
size ?? badgeSize
private var displayHeight: CGFloat {
size ?? badgeHeight
}
public var body: some View {
Text("\(value)")
.font(.system(size: valueFontSize, weight: .black, design: .rounded))
.foregroundStyle(.white)
.frame(width: displaySize, height: displaySize)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.padding(.horizontal, badgePadding)
.frame(minWidth: displayHeight, minHeight: displayHeight)
.background(
Circle()
Capsule()
.fill(color)
)
.accessibilityLabel("\(value)")

View File

@ -33,31 +33,17 @@ public struct AppIconConfig: Sendable {
self.accentColor = accentColor
}
// MARK: - Preset Configurations
// MARK: - Example Preset Configurations
// Game-specific presets should be defined in the respective apps as extensions.
/// Baccarat game icon configuration.
public static let baccarat = AppIconConfig(
title: "BACCARAT",
iconSymbol: "suit.spade.fill"
)
/// Blackjack game icon configuration.
public static let blackjack = AppIconConfig(
title: "BLACKJACK",
subtitle: "21",
iconSymbol: "suit.club.fill",
primaryColor: Color(red: 0.05, green: 0.35, blue: 0.15),
secondaryColor: Color(red: 0.03, green: 0.2, blue: 0.1)
)
/// Poker game icon configuration.
/// Poker game icon configuration (example preset).
public static let poker = AppIconConfig(
title: "POKER",
iconSymbol: "suit.diamond.fill",
accentColor: .red
)
/// Roulette game icon configuration.
/// Roulette game icon configuration (example preset).
public static let roulette = AppIconConfig(
title: "ROULETTE",
iconSymbol: "circle.grid.3x3.fill",
@ -195,22 +181,20 @@ private struct DiamondPatternOverlay: View {
// MARK: - Preview
#Preview("Baccarat Icon") {
AppIconView(config: .baccarat, size: 512)
#Preview("Poker Icon") {
AppIconView(config: .poker, size: 512)
.padding()
.background(Color.gray)
}
#Preview("Blackjack Icon") {
AppIconView(config: .blackjack, size: 512)
#Preview("Roulette Icon") {
AppIconView(config: .roulette, size: 512)
.padding()
.background(Color.gray)
}
#Preview("All Icons") {
HStack(spacing: 20) {
AppIconView(config: .baccarat, size: 200)
AppIconView(config: .blackjack, size: 200)
AppIconView(config: .poker, size: 200)
AppIconView(config: .roulette, size: 200)
}

View File

@ -134,6 +134,6 @@ public struct IconExportView: View {
}
#Preview("Icon Export") {
IconExportView(config: .baccarat)
IconExportView(config: .poker)
}

View File

@ -38,26 +38,10 @@ public struct LaunchScreenConfig: Sendable {
self.showLoadingIndicator = showLoadingIndicator
}
// MARK: - Preset Configurations
// MARK: - Example Preset Configurations
// Game-specific presets should be defined in the respective apps as extensions.
/// Baccarat game launch screen configuration.
public static let baccarat = LaunchScreenConfig(
title: "BACCARAT",
tagline: "The Classic Casino Card Game",
iconSymbols: ["suit.spade.fill", "suit.heart.fill"]
)
/// Blackjack game launch screen configuration.
public static let blackjack = LaunchScreenConfig(
title: "BLACKJACK",
subtitle: "21",
tagline: "Beat the Dealer",
iconSymbols: ["suit.club.fill", "suit.diamond.fill"],
primaryColor: Color(red: 0.05, green: 0.35, blue: 0.15),
secondaryColor: Color(red: 0.03, green: 0.2, blue: 0.1)
)
/// Poker game launch screen configuration.
/// Poker game launch screen configuration (example preset).
public static let poker = LaunchScreenConfig(
title: "POKER",
tagline: "Texas Hold'em",
@ -339,15 +323,11 @@ public struct StaticLaunchScreenView: View {
// MARK: - Preview
#Preview("Baccarat Launch") {
LaunchScreenView(config: .baccarat)
}
#Preview("Blackjack Launch") {
LaunchScreenView(config: .blackjack)
#Preview("Poker Launch") {
LaunchScreenView(config: .poker)
}
#Preview("Static Launch") {
StaticLaunchScreenView(config: .baccarat)
StaticLaunchScreenView(config: .poker)
}

View File

@ -142,7 +142,7 @@ public enum ActionButtonStyle {
}
}
#Preview("BlackJack Action Buttons") {
#Preview("Casino Action Buttons") {
ZStack {
Color.CasinoTable.felt.ignoresSafeArea()

View File

@ -76,14 +76,14 @@ public struct GameOverView: View {
// Stats card
VStack(spacing: CasinoDesign.Spacing.medium) {
StatRow(
GameOverStatRow(
label: String(localized: "Rounds Played", bundle: .module),
value: "\(roundsPlayed)",
fontSize: statsFontSize
)
ForEach(additionalStats.indices, id: \.self) { index in
StatRow(
GameOverStatRow(
label: additionalStats[index].0,
value: additionalStats[index].1,
fontSize: statsFontSize
@ -170,7 +170,7 @@ public struct GameOverView: View {
}
/// A single stat row for the game over view.
private struct StatRow: View {
private struct GameOverStatRow: View {
let label: String
let value: String
let fontSize: CGFloat

View File

@ -0,0 +1,402 @@
//
// SessionViews.swift
// CasinoKit
//
// Reusable UI components for session management.
// Works with any game that uses the session system.
//
import SwiftUI
// MARK: - End Session Button
/// A button that triggers ending the current session.
public struct EndSessionButton: View {
let action: () -> Void
public init(action: @escaping () -> Void) {
self.action = action
}
public var body: some View {
Button {
action()
} label: {
HStack(spacing: CasinoDesign.Spacing.small) {
Image(systemName: "flag.checkered")
Text(String(localized: "End Session", bundle: .module))
}
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, CasinoDesign.Spacing.large)
.padding(.vertical, CasinoDesign.Spacing.medium)
.background(
Capsule()
.fill(Color.orange.opacity(CasinoDesign.Opacity.strong))
)
}
}
}
// MARK: - End Session Confirmation
/// Confirmation dialog for ending a session.
public struct EndSessionConfirmation: View {
let sessionDuration: TimeInterval
let netResult: Int
let onConfirm: () -> Void
let onCancel: () -> Void
public init(
sessionDuration: TimeInterval,
netResult: Int,
onConfirm: @escaping () -> Void,
onCancel: @escaping () -> Void
) {
self.sessionDuration = sessionDuration
self.netResult = netResult
self.onConfirm = onConfirm
self.onCancel = onCancel
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.large) {
// Header
VStack(spacing: CasinoDesign.Spacing.small) {
Image(systemName: "flag.checkered.circle.fill")
.font(.system(size: CasinoDesign.BaseFontSize.xxLarge * 2))
.foregroundStyle(.orange)
Text(String(localized: "End Session?", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.title, weight: .bold))
.foregroundStyle(.white)
}
// Session summary
VStack(spacing: CasinoDesign.Spacing.medium) {
HStack {
Text(String(localized: "Duration", bundle: .module))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
Spacer()
Text(SessionFormatter.formatDuration(sessionDuration))
.foregroundStyle(.white)
.bold()
}
HStack {
Text(String(localized: "Net Result", bundle: .module))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
Spacer()
Text(SessionFormatter.formatMoney(netResult))
.foregroundStyle(netResult >= 0 ? .green : .red)
.bold()
}
}
.font(.system(size: CasinoDesign.BaseFontSize.body))
.padding()
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
.fill(Color.black.opacity(CasinoDesign.Opacity.light))
)
// Info text
Text(String(localized: "Your session will be saved to history and a new session will start.", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.small))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
.multilineTextAlignment(.center)
// Buttons
HStack(spacing: CasinoDesign.Spacing.medium) {
Button {
onCancel()
} label: {
Text(String(localized: "Cancel", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, CasinoDesign.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
.fill(Color.gray.opacity(CasinoDesign.Opacity.medium))
)
}
Button {
onConfirm()
} label: {
Text(String(localized: "End Session", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, CasinoDesign.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
.fill(Color.orange)
)
}
}
}
.padding(CasinoDesign.Spacing.xLarge)
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.xLarge)
.fill(Color.Sheet.background)
)
.padding(CasinoDesign.Spacing.xLarge)
}
}
// MARK: - Session Summary Row
/// A compact row showing session summary - works with any game.
public struct SessionSummaryRow: View {
let styleDisplayName: String
let duration: TimeInterval
let roundsPlayed: Int
let netResult: Int
let startTime: Date
let isActive: Bool
let endReason: SessionEndReason?
public init(
styleDisplayName: String,
duration: TimeInterval,
roundsPlayed: Int,
netResult: Int,
startTime: Date,
isActive: Bool,
endReason: SessionEndReason?
) {
self.styleDisplayName = styleDisplayName
self.duration = duration
self.roundsPlayed = roundsPlayed
self.netResult = netResult
self.startTime = startTime
self.isActive = isActive
self.endReason = endReason
}
private var resultColor: Color {
if netResult > 0 { return .green }
if netResult < 0 { return .red }
return .gray
}
private var resultIcon: String {
if netResult > 0 { return "arrow.up.circle.fill" }
if netResult < 0 { return "arrow.down.circle.fill" }
return "equal.circle.fill"
}
public var body: some View {
HStack(spacing: CasinoDesign.Spacing.medium) {
// Result indicator
Image(systemName: resultIcon)
.font(.system(size: CasinoDesign.BaseFontSize.xLarge))
.foregroundStyle(resultColor)
// Session info
VStack(alignment: .leading, spacing: CasinoDesign.Spacing.xxSmall) {
HStack {
Text(styleDisplayName)
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold))
.foregroundStyle(.white)
if isActive {
Text(String(localized: "ACTIVE", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.xxSmall, weight: .bold))
.foregroundStyle(.green)
.padding(.horizontal, CasinoDesign.Spacing.xSmall)
.padding(.vertical, CasinoDesign.Spacing.xxSmall)
.background(
Capsule()
.fill(Color.green.opacity(CasinoDesign.Opacity.hint))
)
}
}
HStack(spacing: CasinoDesign.Spacing.medium) {
Label(
SessionFormatter.formatDuration(duration),
systemImage: "clock"
)
Label(
"\(roundsPlayed)",
systemImage: "suit.spade.fill"
)
}
.font(.system(size: CasinoDesign.BaseFontSize.small))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
Text(SessionFormatter.formatSessionDate(startTime))
.font(.system(size: CasinoDesign.BaseFontSize.xxSmall))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
}
Spacer()
// Net result
VStack(alignment: .trailing, spacing: CasinoDesign.Spacing.xxSmall) {
Text(SessionFormatter.formatMoney(netResult))
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .bold, design: .rounded))
.foregroundStyle(resultColor)
if let reason = endReason {
Text(reason == .brokeOut
? String(localized: "Bust", bundle: .module)
: String(localized: "Ended", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.xxSmall))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
}
}
}
.padding(CasinoDesign.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
.fill(Color.white.opacity(CasinoDesign.Opacity.subtle))
)
}
}
// MARK: - Current Session Header
/// A header showing the current active session with end button.
public struct CurrentSessionHeader: View {
let duration: TimeInterval
let roundsPlayed: Int
let netResult: Int
let onEndSession: () -> Void
public init(
duration: TimeInterval,
roundsPlayed: Int,
netResult: Int,
onEndSession: @escaping () -> Void
) {
self.duration = duration
self.roundsPlayed = roundsPlayed
self.netResult = netResult
self.onEndSession = onEndSession
}
private var resultColor: Color {
if netResult > 0 { return .green }
if netResult < 0 { return .red }
return .white
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.medium) {
// Header row
HStack {
Label(String(localized: "Current Session", bundle: .module), systemImage: "play.circle.fill")
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold))
.foregroundStyle(.green)
Spacer()
EndSessionButton(action: onEndSession)
}
Divider()
.background(Color.white.opacity(CasinoDesign.Opacity.hint))
// Stats row
HStack(spacing: CasinoDesign.Spacing.large) {
StatColumn(
value: SessionFormatter.formatDuration(duration),
label: String(localized: "Duration", bundle: .module)
)
StatColumn(
value: "\(roundsPlayed)",
label: String(localized: "Hands", bundle: .module)
)
StatColumn(
value: SessionFormatter.formatMoney(netResult),
label: String(localized: "Net", bundle: .module),
valueColor: resultColor
)
}
}
.padding(CasinoDesign.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.large)
.fill(Color.green.opacity(CasinoDesign.Opacity.subtle))
.overlay(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.large)
.stroke(Color.green.opacity(CasinoDesign.Opacity.light), lineWidth: CasinoDesign.LineWidth.thin)
)
)
}
}
// MARK: - Stat Column
// StatColumn is now provided by StatisticsComponents.swift
// MARK: - Game Stats Display Row
/// A row for displaying a single stat item.
public struct GameStatRow: View {
let item: StatDisplayItem
public init(item: StatDisplayItem) {
self.item = item
}
public var body: some View {
HStack {
// Icon
ZStack {
Circle()
.fill(item.iconColor)
.frame(width: CasinoDesign.Spacing.xLarge + CasinoDesign.Spacing.small,
height: CasinoDesign.Spacing.xLarge + CasinoDesign.Spacing.small)
Image(systemName: item.icon)
.font(.system(size: CasinoDesign.BaseFontSize.small, weight: .bold))
.foregroundStyle(.white)
}
Text(item.label)
.font(.system(size: CasinoDesign.BaseFontSize.body))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
Spacer()
Text(item.value)
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold, design: .rounded))
.foregroundStyle(item.valueColor)
}
}
}
// MARK: - Preview
#Preview("End Session Confirmation") {
ZStack {
Color.black.ignoresSafeArea()
EndSessionConfirmation(
sessionDuration: 3600 + 45 * 60,
netResult: -250,
onConfirm: {},
onCancel: {}
)
}
}
#Preview("Current Session Header") {
ZStack {
Color.Sheet.background.ignoresSafeArea()
CurrentSessionHeader(
duration: 1850,
roundsPlayed: 12,
netResult: 350,
onEndSession: {}
)
.padding()
}
}

View File

@ -0,0 +1,581 @@
//
// StatisticsComponents.swift
// CasinoKit
//
// Reusable UI components for statistics display.
// Used in StatisticsSheetView across all casino games.
//
import SwiftUI
// MARK: - Statistics Tab
/// Tab options for statistics views.
public enum StatisticsTab: CaseIterable, Sendable {
case current
case global
case history
public var title: String {
switch self {
case .current: return String(localized: "Current", bundle: .module)
case .global: return String(localized: "Global", bundle: .module)
case .history: return String(localized: "History", bundle: .module)
}
}
public var icon: String {
switch self {
case .current: return "play.circle.fill"
case .global: return "globe"
case .history: return "clock.arrow.circlepath"
}
}
}
// MARK: - Statistics Tab Selector
/// A tab selector for switching between Current, Global, and History tabs.
public struct StatisticsTabSelector: View {
@Binding public var selectedTab: StatisticsTab
public init(selectedTab: Binding<StatisticsTab>) {
self._selectedTab = selectedTab
}
public var body: some View {
HStack(spacing: 0) {
ForEach(StatisticsTab.allCases, id: \.self) { tab in
Button {
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration)) {
selectedTab = tab
}
} label: {
VStack(spacing: CasinoDesign.Spacing.xSmall) {
Image(systemName: tab.icon)
.font(.system(size: CasinoDesign.BaseFontSize.large))
Text(tab.title)
.font(.system(size: CasinoDesign.BaseFontSize.small, weight: .medium))
}
.foregroundStyle(selectedTab == tab ? Color.Sheet.accent : .white.opacity(CasinoDesign.Opacity.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, CasinoDesign.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
.fill(selectedTab == tab ? Color.Sheet.accent.opacity(CasinoDesign.Opacity.hint) : .clear)
)
}
}
}
.padding(.horizontal)
}
}
// MARK: - Stat Column
/// A vertical column displaying a value and label, used in summary sections.
public struct StatColumn: View {
public let value: String
public let label: String
public var valueColor: Color
public init(value: String, label: String, valueColor: Color = .white) {
self.value = value
self.label = label
self.valueColor = valueColor
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.xSmall) {
Text(value)
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .bold, design: .rounded))
.foregroundStyle(valueColor)
Text(label)
.font(.system(size: CasinoDesign.BaseFontSize.small))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Outcome Circle
/// A circular indicator showing win/loss/push counts with a colored ring.
public struct OutcomeCircle: View {
public let label: String
public let count: Int
public let color: Color
// Local size constants
private let circleSize: CGFloat = 48
private let innerSize: CGFloat = 24
public init(label: String, count: Int, color: Color) {
self.label = label
self.count = count
self.color = color
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.small) {
Text(label)
.font(.system(size: CasinoDesign.BaseFontSize.small))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
Text("\(count)")
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .bold))
.foregroundStyle(.white)
ZStack {
Circle()
.fill(Color.black.opacity(CasinoDesign.Opacity.light))
.frame(width: circleSize, height: circleSize)
Circle()
.stroke(color, lineWidth: CasinoDesign.LineWidth.thick)
.frame(width: circleSize, height: circleSize)
Circle()
.fill(color.opacity(CasinoDesign.Opacity.medium))
.frame(width: innerSize, height: innerSize)
}
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Stat Row
/// A horizontal row with icon, label, and value.
public struct StatRow: View {
public let icon: String
public let label: String
public let value: String
public var valueColor: Color
// Local size constants
private let iconWidth: CGFloat = 32
public init(icon: String, label: String, value: String, valueColor: Color = .white) {
self.icon = icon
self.label = label
self.value = value
self.valueColor = valueColor
}
public var body: some View {
HStack {
Image(systemName: icon)
.font(.system(size: CasinoDesign.BaseFontSize.large))
.foregroundStyle(Color.Sheet.accent)
.frame(width: iconWidth)
Text(label)
.font(.system(size: CasinoDesign.BaseFontSize.body))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
Spacer()
Text(value)
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold, design: .rounded))
.foregroundStyle(valueColor)
}
}
}
// MARK: - Chip Stat Row
/// A row displaying a stat with a colored circular icon, commonly used for chip/money stats.
public struct ChipStatRow: View {
public let icon: String
public let iconColor: Color
public let label: String
public let value: String
// Local size constants
private let chipIconSize: CGFloat = 28
public init(icon: String, iconColor: Color, label: String, value: String) {
self.icon = icon
self.iconColor = iconColor
self.label = label
self.value = value
}
public var body: some View {
HStack {
ZStack {
Circle()
.fill(iconColor)
.frame(width: chipIconSize, height: chipIconSize)
Image(systemName: icon)
.font(.system(size: CasinoDesign.BaseFontSize.small, weight: .bold))
.foregroundStyle(.white)
}
Text(label)
.font(.system(size: CasinoDesign.BaseFontSize.body))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
Spacer()
Text(value)
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .semibold, design: .rounded))
.foregroundStyle(Color.Sheet.accent)
}
}
}
// MARK: - No Active Session View
/// Placeholder view shown when there's no active session.
public struct NoActiveSessionView: View {
public init() {}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.large) {
Image(systemName: "play.slash")
.font(.system(size: CasinoDesign.BaseFontSize.xxLarge * 2))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
Text(String(localized: "No Active Session", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .semibold))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
Text(String(localized: "Start playing to begin tracking your session.", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.body))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
.multilineTextAlignment(.center)
}
.padding(CasinoDesign.Spacing.xxLarge)
}
}
// MARK: - Empty History View
/// Placeholder view shown when session history is empty.
public struct EmptyHistoryView: View {
public init() {}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.large) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: CasinoDesign.BaseFontSize.xxLarge * 2))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
Text(String(localized: "No Session History", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.large, weight: .semibold))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
Text(String(localized: "Completed sessions will appear here.", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.body))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.light))
.multilineTextAlignment(.center)
}
.padding(CasinoDesign.Spacing.xxLarge)
}
}
// MARK: - Session Performance Section
/// A reusable section showing session win/loss performance stats.
public struct SessionPerformanceSection: View {
public let winningSessions: Int
public let losingSessions: Int
public let bestSession: Int
public let worstSession: Int
public init(
winningSessions: Int,
losingSessions: Int,
bestSession: Int,
worstSession: Int
) {
self.winningSessions = winningSessions
self.losingSessions = losingSessions
self.bestSession = bestSession
self.worstSession = worstSession
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.medium) {
HStack {
Text(String(localized: "Winning sessions", bundle: .module))
Spacer()
Text("\(winningSessions)")
.foregroundStyle(.green)
.bold()
}
HStack {
Text(String(localized: "Losing sessions", bundle: .module))
Spacer()
Text("\(losingSessions)")
.foregroundStyle(.red)
.bold()
}
Divider().background(Color.white.opacity(CasinoDesign.Opacity.hint))
HStack {
Text(String(localized: "Best session", bundle: .module))
Spacer()
Text(SessionFormatter.formatMoney(bestSession))
.foregroundStyle(.green)
.bold()
}
HStack {
Text(String(localized: "Worst session", bundle: .module))
Spacer()
Text(SessionFormatter.formatMoney(worstSession))
.foregroundStyle(.red)
.bold()
}
}
.font(.system(size: CasinoDesign.BaseFontSize.body))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
}
}
// MARK: - Chips Stats Section
/// A reusable section showing chip/betting statistics.
public struct ChipsStatsSection: View {
public let totalWinnings: Int
public let biggestWin: Int
public let biggestLoss: Int
public let totalBetAmount: Int
public let averageBet: Int?
public let biggestBet: Int
public init(
totalWinnings: Int,
biggestWin: Int,
biggestLoss: Int,
totalBetAmount: Int,
averageBet: Int?,
biggestBet: Int
) {
self.totalWinnings = totalWinnings
self.biggestWin = biggestWin
self.biggestLoss = biggestLoss
self.totalBetAmount = totalBetAmount
self.averageBet = averageBet
self.biggestBet = biggestBet
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.medium) {
ChipStatRow(
icon: "chart.line.uptrend.xyaxis",
iconColor: totalWinnings >= 0 ? .green : .red,
label: String(localized: "Total gain", bundle: .module),
value: SessionFormatter.formatMoney(totalWinnings)
)
ChipStatRow(
icon: "arrow.up.circle.fill",
iconColor: .green,
label: String(localized: "Best gain", bundle: .module),
value: SessionFormatter.formatMoney(biggestWin)
)
ChipStatRow(
icon: "arrow.down.circle.fill",
iconColor: .red,
label: String(localized: "Worst loss", bundle: .module),
value: SessionFormatter.formatMoney(biggestLoss)
)
Divider().background(Color.white.opacity(CasinoDesign.Opacity.hint))
ChipStatRow(
icon: "plusminus.circle.fill",
iconColor: .blue,
label: String(localized: "Total bet", bundle: .module),
value: "$\(totalBetAmount)"
)
if let avg = averageBet {
ChipStatRow(
icon: "equal.circle.fill",
iconColor: .purple,
label: String(localized: "Average bet", bundle: .module),
value: "$\(avg)"
)
}
ChipStatRow(
icon: "star.circle.fill",
iconColor: .orange,
label: String(localized: "Biggest bet", bundle: .module),
value: "$\(biggestBet)"
)
}
}
}
// MARK: - Balance Section
/// A reusable section showing starting/ending balance for a session.
public struct BalanceSection: View {
public let startingBalance: Int
public let endingBalance: Int
public let netResult: Int
public init(startingBalance: Int, endingBalance: Int, netResult: Int) {
self.startingBalance = startingBalance
self.endingBalance = endingBalance
self.netResult = netResult
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.medium) {
HStack {
Text(String(localized: "Starting balance", bundle: .module))
Spacer()
Text("$\(startingBalance)")
.bold()
}
HStack {
Text(String(localized: "Ending balance", bundle: .module))
Spacer()
Text("$\(endingBalance)")
.foregroundStyle(netResult >= 0 ? .green : .red)
.bold()
}
}
.font(.system(size: CasinoDesign.BaseFontSize.body))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
}
}
// MARK: - Session Header
/// Header showing session date, end reason, net result, and win rate.
public struct SessionDetailHeader: View {
public let startTime: Date
public let endReason: SessionEndReason?
public let netResult: Int
public let winRate: Double
public init(
startTime: Date,
endReason: SessionEndReason?,
netResult: Int,
winRate: Double
) {
self.startTime = startTime
self.endReason = endReason
self.netResult = netResult
self.winRate = winRate
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.small) {
HStack {
VStack(alignment: .leading, spacing: CasinoDesign.Spacing.xxSmall) {
Text(SessionFormatter.formatSessionDate(startTime))
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white)
if let endReason = endReason {
HStack(spacing: CasinoDesign.Spacing.xSmall) {
Image(systemName: endReason == .brokeOut ? "xmark.circle.fill" : "checkmark.circle.fill")
.foregroundStyle(endReason == .brokeOut ? .red : .green)
Text(endReason == .brokeOut
? String(localized: "Ran out of chips", bundle: .module)
: String(localized: "Ended manually", bundle: .module))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
}
.font(.system(size: CasinoDesign.BaseFontSize.small))
}
}
Spacer()
VStack(alignment: .trailing, spacing: CasinoDesign.Spacing.xxSmall) {
Text(SessionFormatter.formatMoney(netResult))
.font(.system(size: CasinoDesign.BaseFontSize.xLarge, weight: .bold, design: .rounded))
.foregroundStyle(netResult >= 0 ? .green : .red)
Text(SessionFormatter.formatPercent(winRate) + " " + String(localized: "win rate", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.small))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
.fill(Color.Sheet.sectionFill)
)
.padding(.horizontal)
}
}
// MARK: - Delete Session Button
/// A styled button for deleting a session.
public struct DeleteSessionButton: View {
public let action: () -> Void
public init(action: @escaping () -> Void) {
self.action = action
}
public var body: some View {
Button(role: .destructive, action: action) {
HStack {
Image(systemName: "trash")
Text(String(localized: "Delete Session", bundle: .module))
}
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .medium))
.frame(maxWidth: .infinity)
.padding(CasinoDesign.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
.fill(Color.red.opacity(CasinoDesign.Opacity.hint))
)
.foregroundStyle(.red)
}
.padding(.horizontal)
.padding(.top, CasinoDesign.Spacing.large)
}
}
// MARK: - Previews
#Preview("Statistics Components") {
ScrollView {
VStack(spacing: 24) {
StatisticsTabSelector(selectedTab: .constant(.current))
HStack(spacing: CasinoDesign.Spacing.large) {
StatColumn(value: "15", label: "Sessions")
StatColumn(value: "234", label: "Hands")
StatColumn(value: "52.3%", label: "Win Rate", valueColor: .green)
}
.padding()
HStack(spacing: CasinoDesign.Spacing.medium) {
OutcomeCircle(label: "Won", count: 45, color: .white)
OutcomeCircle(label: "Lost", count: 38, color: .red)
OutcomeCircle(label: "Push", count: 12, color: .gray)
}
.padding()
VStack(spacing: CasinoDesign.Spacing.medium) {
StatRow(icon: "clock", label: "Total game time", value: "02h 15min")
ChipStatRow(icon: "chart.line.uptrend.xyaxis", iconColor: .green, label: "Total gain", value: "$1,250")
}
.padding()
NoActiveSessionView()
EmptyHistoryView()
}
}
.background(Color.Sheet.background)
}

View File

@ -57,16 +57,17 @@ public struct SettingsToggle: View {
/// A segmented picker for animation speed (Fast/Normal/Slow).
public struct SpeedPicker: View {
/// Binding to the speed value (0.5 = fast, 1.0 = normal, 2.0 = slow).
/// Binding to the speed value (multiplier for animation duration).
@Binding public var speed: Double
/// The accent color for the selected button.
public let accentColor: Color
/// Speed options using centralized CasinoDesign constants.
private let options: [(String, Double)] = [
("Fast", 0.5),
("Normal", 1.0),
("Slow", 2.0)
("Fast", CasinoDesign.DealingSpeed.fast),
("Normal", CasinoDesign.DealingSpeed.normal),
("Slow", CasinoDesign.DealingSpeed.slow)
]
/// Creates a speed picker.

View File

@ -10,6 +10,7 @@ import SwiftUI
/// Welcome sheet shown on first launch of a game.
public struct WelcomeSheet: View {
let gameName: String
let gameEmoji: String
let features: [WelcomeFeature]
let onStartTutorial: () -> Void
let onStartPlaying: () -> Void
@ -20,13 +21,51 @@ public struct WelcomeSheet: View {
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = CasinoDesign.IconSize.large
@ScaledMetric(relativeTo: .body) private var buttonPadding: CGFloat = CasinoDesign.Spacing.medium
/// Creates a welcome sheet with automatic onboarding state management.
///
/// This initializer handles the common pattern of:
/// - "Show Me How" completes welcome and triggers hint display
/// - "Start Playing" skips all hints and completes welcome
///
/// - Parameters:
/// - gameName: The name of the game to display
/// - gameEmoji: An emoji representing the game
/// - features: List of features to highlight
/// - onboarding: The onboarding state to manage (must have hint keys registered)
/// - onDismiss: Called after the sheet is dismissed
/// - onShowHints: Called when user chooses "Show Me How" - use this to trigger tooltip display
public init(
gameName: String,
gameEmoji: String = "🎰",
features: [WelcomeFeature],
onboarding: OnboardingState,
onDismiss: @escaping () -> Void,
onShowHints: @escaping () -> Void
) {
self.gameName = gameName
self.gameEmoji = gameEmoji
self.features = features
self.onStartTutorial = {
onboarding.completeWelcome()
onDismiss()
onShowHints()
}
self.onStartPlaying = {
onboarding.skipOnboarding()
onDismiss()
}
}
/// Creates a welcome sheet with custom callbacks for full control.
public init(
gameName: String,
gameEmoji: String = "🎰",
features: [WelcomeFeature],
onStartTutorial: @escaping () -> Void,
onStartPlaying: @escaping () -> Void
) {
self.gameName = gameName
self.gameEmoji = gameEmoji
self.features = features
self.onStartTutorial = onStartTutorial
self.onStartPlaying = onStartPlaying
@ -84,17 +123,10 @@ public struct WelcomeSheet: View {
.padding(.horizontal, CasinoDesign.Spacing.large)
}
},
onDone: {}
onDone: onStartPlaying // Done button = Start Playing
)
}
private var gameEmoji: String {
switch gameName.lowercased() {
case "blackjack": return "🃏"
case "baccarat": return "🎴"
default: return "🎰"
}
}
}
// MARK: - Feature Row
@ -149,22 +181,23 @@ public struct WelcomeFeature: Identifiable {
#Preview {
WelcomeSheet(
gameName: "Blackjack",
gameName: "Casino Game",
gameEmoji: "🎰",
features: [
WelcomeFeature(
icon: "target",
title: "Beat the Dealer",
description: "Get closer to 21 than the dealer without going over"
title: "Exciting Gameplay",
description: "Experience the thrill of the casino"
),
WelcomeFeature(
icon: "lightbulb.fill",
title: "Learn Strategy",
description: "Built-in hints show optimal plays based on basic strategy"
description: "Built-in hints show optimal plays"
),
WelcomeFeature(
icon: "dollarsign.circle",
title: "Practice Free",
description: "Start with $1,000 and play risk-free"
description: "Start with virtual chips and play risk-free"
)
],
onStartTutorial: {},

View File

@ -41,21 +41,8 @@ struct CardTests {
#expect(deck.cardsRemaining == 52)
}
@Test("Card baccarat values are correct")
func baccaratValues() {
#expect(Rank.ace.baccaratValue == 1)
#expect(Rank.five.baccaratValue == 5)
#expect(Rank.ten.baccaratValue == 0)
#expect(Rank.king.baccaratValue == 0)
}
@Test("Card blackjack values are correct")
func blackjackValues() {
#expect(Rank.ace.blackjackValue == 11)
#expect(Rank.five.blackjackValue == 5)
#expect(Rank.ten.blackjackValue == 10)
#expect(Rank.king.blackjackValue == 10)
}
// Game-specific card value tests have been moved to the respective app test targets
// (BlackjackTests and BaccaratTests) since the value extensions are now in the apps.
@Test("Card display format is correct")
func cardDisplay() {