Compare commits
19 Commits
fd6e3355a5
...
ed1dabfe18
| Author | SHA1 | Date | |
|---|---|---|---|
| ed1dabfe18 | |||
| c9b2a9e692 | |||
| 3be7fc5884 | |||
| 43727534e6 | |||
| bda234a3bb | |||
| 04fc1542f5 | |||
| ca742eb73f | |||
| e1655ce20c | |||
| 3aa1ed77ae | |||
| 1fe7bbb274 | |||
| abf4ba9b97 | |||
| 178d28ca6c | |||
| 2a55a16227 | |||
| f1b834c47e | |||
| 10d3d02cb0 | |||
| 9433ced1aa | |||
| 247435a405 | |||
| 982d54ed1d | |||
| a9b4f95bb4 |
132
Agents.md
132
Agents.md
@ -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`.
|
||||
|
||||
@ -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
|
||||
|
||||
// Update balance
|
||||
data.balance = balance
|
||||
|
||||
// Update statistics
|
||||
data.totalWinnings += netWinnings
|
||||
if netWinnings > data.biggestWin {
|
||||
data.biggestWin = netWinnings
|
||||
}
|
||||
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]
|
||||
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
|
||||
|
||||
// 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
|
||||
}
|
||||
// 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 = []
|
||||
|
||||
// Save the reset state (keeps lifetime stats, resets balance and session history)
|
||||
saveGame()
|
||||
currentPhase = .betting
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Returns lifetime statistics from saved data.
|
||||
var lifetimeStats: BaccaratGameData {
|
||||
persistence.data
|
||||
balance = settings.startingBalance
|
||||
currentSession = nil
|
||||
sessionHistory = []
|
||||
sessionRoundHistories = [:]
|
||||
roundHistory = []
|
||||
startNewSession()
|
||||
newRoundInternal()
|
||||
|
||||
// Play new game sound
|
||||
sound.playNewRound()
|
||||
}
|
||||
|
||||
/// Applies new settings (call after settings change).
|
||||
|
||||
111
Baccarat/Baccarat/Models/BaccaratStats.swift
Normal file
111
Baccarat/Baccarat/Models/BaccaratStats.swift
Normal 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>
|
||||
|
||||
30
Baccarat/Baccarat/Models/Card+Baccarat.swift
Normal file
30
Baccarat/Baccarat/Models/Card+Baccarat.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
// TableLimits is now provided by CasinoKit
|
||||
|
||||
/// 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
@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
40
Baccarat/Baccarat/Theme/BrandingConfig+Baccarat.swift
Normal file
40
Baccarat/Baccarat/Theme/BrandingConfig+Baccarat.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
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: - Win Distribution Section
|
||||
// MARK: - Tab Selector (uses CasinoKit.StatisticsTabSelector)
|
||||
|
||||
private var winDistributionSection: some View {
|
||||
SheetSection(title: "WIN DISTRIBUTION", icon: "trophy.fill") {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
// 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: - Global Stats Content
|
||||
|
||||
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) {
|
||||
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
|
||||
)
|
||||
WinStatCompact(
|
||||
label: String(localized: "Banker"),
|
||||
count: gameStats.bankerWins,
|
||||
color: .red
|
||||
)
|
||||
WinStatCompact(
|
||||
label: String(localized: "Tie"),
|
||||
count: gameStats.ties,
|
||||
color: .green
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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: 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) {
|
||||
WinStatView(
|
||||
title: String(localized: "Player"),
|
||||
count: playerWins,
|
||||
percentage: percentage(playerWins),
|
||||
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
|
||||
)
|
||||
|
||||
WinStatView(
|
||||
title: String(localized: "Tie"),
|
||||
count: tieCount,
|
||||
percentage: percentage(tieCount),
|
||||
color: .green
|
||||
)
|
||||
|
||||
WinStatView(
|
||||
title: String(localized: "Banker"),
|
||||
count: bankerWins,
|
||||
percentage: percentage(bankerWins),
|
||||
WinStatCompact(
|
||||
label: String(localized: "Banker"),
|
||||
count: session.gameStats.bankerWins,
|
||||
color: .red
|
||||
)
|
||||
}
|
||||
|
||||
// Win bar visualization
|
||||
if totalRounds > 0 {
|
||||
WinDistributionBar(
|
||||
playerWins: playerWins,
|
||||
tieCount: tieCount,
|
||||
bankerWins: bankerWins
|
||||
WinStatCompact(
|
||||
label: String(localized: "Tie"),
|
||||
count: session.gameStats.ties,
|
||||
color: .green
|
||||
)
|
||||
.frame(height: Design.Spacing.large)
|
||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
color: .blue
|
||||
)
|
||||
|
||||
PairStatView(
|
||||
title: String(localized: "B Pair"),
|
||||
count: bankerPairs,
|
||||
percentage: percentage(bankerPairs),
|
||||
color: .red
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// 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: - Big Road Section
|
||||
// MARK: - Road Sections
|
||||
|
||||
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)
|
||||
SheetSection(title: String(localized: "BIG ROAD"), icon: "chart.bar.xaxis") {
|
||||
if state.roundHistory.isEmpty {
|
||||
emptyRoadPlaceholder
|
||||
} else {
|
||||
BigRoadView(results: results)
|
||||
.frame(height: Design.Size.bigRoadHeight)
|
||||
BigRoadView(results: state.roundHistory)
|
||||
.frame(height: 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))
|
||||
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)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: Design.Size.statBoxMinWidth)
|
||||
.padding(Design.Spacing.medium)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(Color.black.opacity(Design.Opacity.overlay))
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.xSmall) {
|
||||
Text("\(count)")
|
||||
.font(.system(size: Design.BaseFontSize.title, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
|
||||
Text(percentage)
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.secondary))
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.accent))
|
||||
}
|
||||
}
|
||||
}
|
||||
// MARK: - Session Detail View
|
||||
|
||||
/// A horizontal bar showing win distribution.
|
||||
private struct WinDistributionBar: View {
|
||||
let playerWins: Int
|
||||
let tieCount: Int
|
||||
let bankerWins: Int
|
||||
private struct SessionDetailView: View {
|
||||
let session: BaccaratSession
|
||||
let styleDisplayName: String
|
||||
let roundHistory: [RoundResult]
|
||||
let onDelete: () -> Void
|
||||
|
||||
private var total: Int { playerWins + tieCount + bankerWins }
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
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))
|
||||
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(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 (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))
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tieCount > 0 {
|
||||
Rectangle()
|
||||
.fill(Color.green)
|
||||
.frame(width: geometry.size.width * CGFloat(tieCount) / CGFloat(total))
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
if bankerWins > 0 {
|
||||
Rectangle()
|
||||
.fill(Color.red)
|
||||
.frame(width: geometry.size.width * CGFloat(bankerWins) / CGFloat(total))
|
||||
// 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."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Big Road scoreboard - a grid showing result patterns.
|
||||
/// Results are arranged in columns, with each column representing a streak of same results.
|
||||
// MARK: - Big Road View
|
||||
|
||||
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()))
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
// Return the minimum of all bet types so chips stay enabled if any can be increased
|
||||
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
|
||||
}
|
||||
if wasBlackjack {
|
||||
blackjackCount += 1
|
||||
}
|
||||
if hadBust {
|
||||
bustCount += 1
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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 {
|
||||
107
Blackjack/Blackjack/Models/BlackjackStats.swift
Normal file
107
Blackjack/Blackjack/Models/BlackjackStats.swift
Normal 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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
43
Blackjack/Blackjack/Theme/BrandingConfig+Blackjack.swift
Normal file
43
Blackjack/Blackjack/Theme/BrandingConfig+Blackjack.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,11 +315,11 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
@ -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))
|
||||
|
||||
@ -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()
|
||||
}
|
||||
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),
|
||||
onDelete: {
|
||||
state.deleteSession(id: session.id)
|
||||
selectedSession = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatMoney(_ amount: Int) -> String {
|
||||
if amount >= 0 {
|
||||
return "+$\(amount)"
|
||||
// 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 {
|
||||
return "-$\(abs(amount))"
|
||||
NoActiveSessionView()
|
||||
}
|
||||
}
|
||||
|
||||
private func formatPercent(_ value: Double) -> String {
|
||||
value.formatted(.number.precision(.fractionLength(1))) + "%"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stat Box
|
||||
|
||||
struct StatBox: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let color: Color
|
||||
// MARK: - Global Stats Content
|
||||
|
||||
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))) + "%"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
// Label
|
||||
Text(label)
|
||||
.font(.system(size: Design.BaseFontSize.body))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
|
||||
Spacer()
|
||||
|
||||
// Count
|
||||
Text("\(count)")
|
||||
.font(.system(size: Design.BaseFontSize.body, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
|
||||
// Progress bar
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
|
||||
.fill(Color.white.opacity(Design.Opacity.subtle))
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
|
||||
.fill(color)
|
||||
.frame(width: geometry.size.width * CGFloat(percentage / 100))
|
||||
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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Design.Spacing.xSmall)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 Blackjack-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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()))
|
||||
}
|
||||
|
||||
|
||||
@ -55,38 +55,32 @@ 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
|
||||
SideBetZoneView(
|
||||
betType: .perfectPairs,
|
||||
betAmount: state.perfectPairsBet,
|
||||
isEnabled: canAddPerfectPairs,
|
||||
isAtMax: isPPAtMax,
|
||||
onTap: { state.placeSideBet(type: .perfectPairs, amount: selectedChip.rawValue) }
|
||||
)
|
||||
.frame(width: Design.Size.sideBetZoneWidth)
|
||||
|
||||
// Main bet (center, takes remaining space)
|
||||
mainBetZone
|
||||
|
||||
// 21+3
|
||||
SideBetZoneView(
|
||||
betType: .twentyOnePlusThree,
|
||||
betAmount: state.twentyOnePlusThreeBet,
|
||||
isEnabled: canAddTwentyOnePlusThree,
|
||||
isAtMax: is21PlusThreeAtMax,
|
||||
onTap: { state.placeSideBet(type: .twentyOnePlusThree, amount: selectedChip.rawValue) }
|
||||
)
|
||||
.frame(width: Design.Size.sideBetZoneWidth)
|
||||
}
|
||||
.frame(height: zoneHeight)
|
||||
} else {
|
||||
// Simple layout: just main bet
|
||||
// Horizontal layout: PP | Main Bet | 21+3
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
// Perfect Pairs
|
||||
SideBetZoneView(
|
||||
betType: .perfectPairs,
|
||||
betAmount: state.perfectPairsBet,
|
||||
isEnabled: canAddPerfectPairs,
|
||||
isAtMax: isPPAtMax,
|
||||
onTap: { state.placeSideBet(type: .perfectPairs, amount: selectedChip.rawValue) }
|
||||
)
|
||||
.frame(width: Design.Size.sideBetZoneWidth)
|
||||
|
||||
// Main bet (center, takes remaining space)
|
||||
mainBetZone
|
||||
.frame(height: zoneHeight)
|
||||
|
||||
// 21+3
|
||||
SideBetZoneView(
|
||||
betType: .twentyOnePlusThree,
|
||||
betAmount: state.twentyOnePlusThreeBet,
|
||||
isEnabled: canAddTwentyOnePlusThree,
|
||||
isAtMax: is21PlusThreeAtMax,
|
||||
onTap: { state.placeSideBet(type: .twentyOnePlusThree, amount: selectedChip.rawValue) }
|
||||
)
|
||||
.frame(width: Design.Size.sideBetZoneWidth)
|
||||
}
|
||||
.frame(height: zoneHeight)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
252
Blackjack/Blackjack/Views/Table/CardStackView.swift
Normal file
252
Blackjack/Blackjack/Views/Table/CardStackView.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
107
Blackjack/Blackjack/Views/Table/HandLabelView.swift
Normal file
107
Blackjack/Blackjack/Views/Table/HandLabelView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,61 +130,25 @@ struct PlayerHandView: View {
|
||||
.animation(.easeInOut(duration: Design.Animation.quick), value: isActive)
|
||||
.animation(.spring(duration: Design.Animation.springDuration), value: hand.result != nil)
|
||||
|
||||
// Hand info
|
||||
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
|
||||
// Bet amount and doubled down indicator
|
||||
if hand.bet > 0 || hand.isDoubledDown {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
if hand.bet > 0 {
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
Image(systemName: "dollarsign.circle.fill")
|
||||
.font(.system(size: iconSize))
|
||||
.foregroundStyle(.yellow)
|
||||
Text("\(hand.bet * (hand.isDoubledDown ? 2 : 1))")
|
||||
.font(.system(size: handNumberSize, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
.font(.system(size: iconSize))
|
||||
.foregroundStyle(.yellow)
|
||||
Text("\(hand.bet * (hand.isDoubledDown ? 2 : 1))")
|
||||
.font(.system(size: handNumberSize, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.yellow)
|
||||
if hand.isDoubledDown {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: iconSize))
|
||||
.foregroundStyle(.purple)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
], 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,
|
||||
PlayerHandView(
|
||||
hand: BlackjackHand(cards: [
|
||||
Card(suit: .hearts, rank: .ace),
|
||||
Card(suit: .spades, rank: .seven)
|
||||
], bet: 100),
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
184
Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift
Normal file
184
Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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/
|
||||
|
||||
@ -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
409
CasinoKit/SESSION_SYSTEM.md
Normal 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 }
|
||||
}
|
||||
```
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
176
CasinoKit/Sources/CasinoKit/Models/CasinoGameState.swift
Normal file
176
CasinoKit/Sources/CasinoKit/Models/CasinoGameState.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
106
CasinoKit/Sources/CasinoKit/Models/GameSettingsProtocol.swift
Normal file
106
CasinoKit/Sources/CasinoKit/Models/GameSettingsProtocol.swift
Normal 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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
287
CasinoKit/Sources/CasinoKit/Models/Session/SessionManager.swift
Normal file
287
CasinoKit/Sources/CasinoKit/Models/Session/SessionManager.swift
Normal 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 }
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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 friction—more 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
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -134,6 +134,6 @@ public struct IconExportView: View {
|
||||
}
|
||||
|
||||
#Preview("Icon Export") {
|
||||
IconExportView(config: .baccarat)
|
||||
IconExportView(config: .poker)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -142,7 +142,7 @@ public enum ActionButtonStyle {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("BlackJack Action Buttons") {
|
||||
#Preview("Casino Action Buttons") {
|
||||
ZStack {
|
||||
Color.CasinoTable.felt.ignoresSafeArea()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
402
CasinoKit/Sources/CasinoKit/Views/Session/SessionViews.swift
Normal file
402
CasinoKit/Sources/CasinoKit/Views/Session/SessionViews.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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: {},
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user