Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-29 11:56:47 -06:00
parent fd6e3355a5
commit a9b4f95bb4
11 changed files with 1090 additions and 414 deletions

View File

@ -118,6 +118,18 @@ final class GameState {
private(set) var biggestLoss: Int = 0 private(set) var biggestLoss: Int = 0
private(set) var blackjackCount: Int = 0 private(set) var blackjackCount: Int = 0
private(set) var bustCount: Int = 0 private(set) var bustCount: Int = 0
private(set) var totalPlayTime: TimeInterval = 0
/// Per-style statistics (keyed by style rawValue).
private(set) var styleStats: [String: StyleStatistics] = [:]
// MARK: - Round Timing
/// When the current round started (for duration tracking).
private var roundStartTime: Date?
/// The bet amount for the current round (tracked for stats).
private var roundBetAmount: Int = 0
// MARK: - Persistence // MARK: - Persistence
@ -336,6 +348,9 @@ final class GameState {
self.persistence = CloudSyncManager<BlackjackGameData>() self.persistence = CloudSyncManager<BlackjackGameData>()
syncSoundSettings() syncSoundSettings()
loadSavedGame() loadSavedGame()
// Start timing for the first round's betting phase
roundStartTime = Date()
} }
/// Syncs sound settings with SoundManager. /// Syncs sound settings with SoundManager.
@ -361,6 +376,15 @@ final class GameState {
self.biggestLoss = data.biggestLoss self.biggestLoss = data.biggestLoss
self.blackjackCount = data.blackjackCount self.blackjackCount = data.blackjackCount
self.bustCount = data.bustCount self.bustCount = data.bustCount
self.totalPlayTime = data.totalPlayTime
self.styleStats = data.styleStats
Design.debugLog("📂 Loaded game data:")
Design.debugLog(" - totalPlayTime: \(data.totalPlayTime) seconds")
Design.debugLog(" - styleStats keys: \(data.styleStats.keys.joined(separator: ", "))")
for (key, stats) in data.styleStats {
Design.debugLog(" - \(key): rounds=\(stats.roundsPlayed), time=\(stats.totalPlayTime)s, totalBet=\(stats.totalBetAmount)")
}
// Set up callback for when iCloud data arrives later // Set up callback for when iCloud data arrives later
persistence.onCloudDataReceived = { [weak self] newData in persistence.onCloudDataReceived = { [weak self] newData in
@ -371,17 +395,23 @@ final class GameState {
self.biggestLoss = newData.biggestLoss self.biggestLoss = newData.biggestLoss
self.blackjackCount = newData.blackjackCount self.blackjackCount = newData.blackjackCount
self.bustCount = newData.bustCount self.bustCount = newData.bustCount
self.totalPlayTime = newData.totalPlayTime
self.styleStats = newData.styleStats
} }
} }
/// Saves current game data to iCloud and local storage. /// Saves current game data to iCloud and local storage.
private func saveGameData() { private func saveGameData() {
// Note: savedRounds are reconstructed from roundHistory with current style
// The actual round data with style is stored during completeRound()
let savedRounds: [SavedRoundResult] = roundHistory.map { result in let savedRounds: [SavedRoundResult] = roundHistory.map { result in
SavedRoundResult( SavedRoundResult(
date: Date(), date: Date(),
gameStyle: settings.gameStyle.rawValue,
mainResult: result.mainHandResult.saveName, mainResult: result.mainHandResult.saveName,
hadSplit: result.hadSplit, hadSplit: result.hadSplit,
totalWinnings: result.totalWinnings totalWinnings: result.totalWinnings,
roundDuration: 0 // Duration tracked separately in styleStats
) )
} }
@ -389,11 +419,13 @@ final class GameState {
lastModified: Date(), lastModified: Date(),
balance: balance, balance: balance,
roundHistory: savedRounds, roundHistory: savedRounds,
styleStats: styleStats,
totalWinnings: totalWinnings, totalWinnings: totalWinnings,
biggestWin: biggestWin, biggestWin: biggestWin,
biggestLoss: biggestLoss, biggestLoss: biggestLoss,
blackjackCount: blackjackCount, blackjackCount: blackjackCount,
bustCount: bustCount bustCount: bustCount,
totalPlayTime: totalPlayTime
) )
persistence.save(data) persistence.save(data)
} }
@ -407,7 +439,11 @@ final class GameState {
biggestLoss = 0 biggestLoss = 0
blackjackCount = 0 blackjackCount = 0
bustCount = 0 bustCount = 0
totalPlayTime = 0
styleStats = [:]
roundHistory = [] roundHistory = []
roundStartTime = nil
roundBetAmount = 0
newRound() newRound()
} }
@ -467,6 +503,10 @@ final class GameState {
func deal() async { func deal() async {
guard canDeal else { return } guard canDeal else { return }
// Track bet amount for statistics (roundStartTime is set in newRound)
roundBetAmount = currentBet + perfectPairsBet + twentyOnePlusThreeBet
Design.debugLog("🎴 Deal started - roundStartTime: \(String(describing: roundStartTime)), roundBetAmount: \(roundBetAmount)")
// Ensure enough cards for a full hand - reshuffle if needed // Ensure enough cards for a full hand - reshuffle if needed
if !engine.canDealNewHand { if !engine.canDealNewHand {
engine.reshuffle() engine.reshuffle()
@ -1022,7 +1062,20 @@ final class GameState {
roundWinnings -= sideBetsTotal roundWinnings -= sideBetsTotal
} }
// Update statistics // Calculate round duration
let roundDuration: TimeInterval
if let startTime = roundStartTime {
roundDuration = Date().timeIntervalSince(startTime)
Design.debugLog("⏱️ Round duration: \(roundDuration) seconds (start: \(startTime))")
} else {
roundDuration = 0
Design.debugLog("⚠️ roundStartTime was nil - duration is 0")
}
totalPlayTime += roundDuration
roundStartTime = nil
Design.debugLog("⏱️ Total play time now: \(totalPlayTime) seconds")
// Update global statistics
totalWinnings += roundWinnings totalWinnings += roundWinnings
if roundWinnings > biggestWin { if roundWinnings > biggestWin {
biggestWin = roundWinnings biggestWin = roundWinnings
@ -1037,6 +1090,46 @@ final class GameState {
bustCount += 1 bustCount += 1
} }
// Determine if this round was a win, loss, push, or surrender for 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
// Update per-style statistics
let styleKey = settings.gameStyle.rawValue
var stats = styleStats[styleKey] ?? StyleStatistics()
stats.roundsPlayed += 1
stats.totalPlayTime += roundDuration
stats.totalWinnings += roundWinnings
stats.totalBetAmount += roundBetAmount
Design.debugLog("📊 Style[\(styleKey)] stats update:")
Design.debugLog(" - roundBetAmount: \(roundBetAmount)")
Design.debugLog(" - stats.totalBetAmount: \(stats.totalBetAmount)")
Design.debugLog(" - stats.totalPlayTime: \(stats.totalPlayTime) seconds")
Design.debugLog(" - stats.roundsPlayed: \(stats.roundsPlayed)")
if roundWinnings > stats.biggestWin {
stats.biggestWin = roundWinnings
}
if roundWinnings < stats.biggestLoss {
stats.biggestLoss = roundWinnings
}
if roundBetAmount > stats.biggestBet {
stats.biggestBet = roundBetAmount
}
if isWin { stats.wins += 1 }
if isLoss { stats.losses += 1 }
if isPush { stats.pushes += 1 }
if isSurrender { stats.surrenders += 1 }
if wasBlackjack { stats.blackjacks += 1 }
if hadBust { stats.busts += 1 }
styleStats[styleKey] = stats
// Create round result with all hand results, per-hand winnings, and side bets // Create round result with all hand results, per-hand winnings, and side bets
let allHandResults = playerHands.map { $0.result ?? .lose } let allHandResults = playerHands.map { $0.result ?? .lose }
@ -1131,6 +1224,10 @@ final class GameState {
twentyOnePlusThreeResult = nil twentyOnePlusThreeResult = nil
showSideBetToasts = false showSideBetToasts = false
// Start timing for the new round (includes betting phase)
roundStartTime = Date()
Design.debugLog("🎰 New round started - roundStartTime: \(roundStartTime!)")
// Reset UI state // Reset UI state
showResultBanner = false showResultBanner = false
lastRoundResult = nil lastRoundResult = nil

View File

@ -1,8 +1,8 @@
// //
// Hand.swift // BlackjackHand.swift
// Blackjack // Blackjack
// //
// Represents a Blackjack hand with value calculation. // Model representing a Blackjack hand with value calculation.
// //
import Foundation import Foundation
@ -68,6 +68,14 @@ struct BlackjackHand: Identifiable, Equatable {
/// Calculates both hard and soft values. /// Calculates both hard and soft values.
private func calculateValues() -> (hard: Int, soft: Int) { 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 hardValue = 0
var aceCount = 0 var aceCount = 0
@ -98,6 +106,18 @@ struct BlackjackHand: Identifiable, Equatable {
return (hardValue, softValue) 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. /// Display string for the hand value.
var valueDisplay: String { var valueDisplay: String {
if isBlackjack { if isBlackjack {

View File

@ -1080,6 +1080,10 @@
} }
} }
}, },
"Average bet" : {
"comment" : "Label for the average bet value in the Statistics Sheet.",
"isCommentAutoGenerated" : true
},
"Baccarat" : { "Baccarat" : {
"comment" : "The name of a casino game.", "comment" : "The name of a casino game.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -1105,6 +1109,7 @@
} }
}, },
"Balance" : { "Balance" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1198,6 +1203,7 @@
} }
}, },
"Best" : { "Best" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1219,6 +1225,10 @@
} }
} }
}, },
"Best gain" : {
"comment" : "Label in the statistics sheet for the player's best single win.",
"isCommentAutoGenerated" : true
},
"Bet 2x minimum" : { "Bet 2x minimum" : {
"comment" : "Betting recommendation based on a true count of 1.", "comment" : "Betting recommendation based on a true count of 1.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -1482,7 +1492,12 @@
} }
} }
}, },
"Biggest bet" : {
"comment" : "Label for the \"Biggest bet\" row in the Statistics Sheet.",
"isCommentAutoGenerated" : true
},
"BIGGEST SWINGS" : { "BIGGEST SWINGS" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1865,6 +1880,10 @@
}, },
"Change table limits, rules, and side bets in settings" : { "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.",
"isCommentAutoGenerated" : true
}, },
"Chips, cards, and results" : { "Chips, cards, and results" : {
"localizations" : { "localizations" : {
@ -3410,6 +3429,10 @@
}, },
"Get closer to 21 than the dealer without going over" : { "Get closer to 21 than the dealer without going over" : {
},
"GLOBAL" : {
"comment" : "Title for the \"Global\" tab in the statistics sheet.",
"isCommentAutoGenerated" : true
}, },
"H17 rule, increases house edge" : { "H17 rule, increases house edge" : {
"localizations" : { "localizations" : {
@ -3455,6 +3478,10 @@
} }
} }
}, },
"Hands played" : {
"comment" : "A label describing the number of hands a player has played in a game.",
"isCommentAutoGenerated" : true
},
"Haptic Feedback" : { "Haptic Feedback" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -3846,6 +3873,10 @@
} }
} }
}, },
"IN GAME STATS" : {
"comment" : "Title of a section in the Statistics Sheet that shows in-game statistics.",
"isCommentAutoGenerated" : true
},
"Increase bets when the count is positive." : { "Increase bets when the count is positive." : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -4164,6 +4195,7 @@
} }
}, },
"Losses" : { "Losses" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -4185,6 +4217,10 @@
} }
} }
}, },
"Lost hands" : {
"comment" : "Label for a circle that shows the number of lost blackjack hands in the statistics sheet.",
"isCommentAutoGenerated" : true
},
"Lower house edge" : { "Lower house edge" : {
"comment" : "Description of a deck count option when the user selects 2 decks.", "comment" : "Description of a deck count option when the user selects 2 decks.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -4438,6 +4474,7 @@
} }
}, },
"Net" : { "Net" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -4826,6 +4863,7 @@
} }
}, },
"OUTCOMES" : { "OUTCOMES" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -5193,7 +5231,12 @@
} }
} }
}, },
"Pushed hands" : {
"comment" : "Label for the \"Pushed hands\" outcome circle in the statistics sheet.",
"isCommentAutoGenerated" : true
},
"Pushes" : { "Pushes" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -5399,6 +5442,7 @@
} }
}, },
"Rounds" : { "Rounds" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -5558,6 +5602,7 @@
}, },
"SESSION SUMMARY" : { "SESSION SUMMARY" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -6724,6 +6769,18 @@
} }
} }
}, },
"Total bet" : {
"comment" : "Label for the total bet value in the Statistics Sheet.",
"isCommentAutoGenerated" : true
},
"Total gain" : {
"comment" : "Label in the Statistics sheet for the total gain (profit or loss) from playing blackjack.",
"isCommentAutoGenerated" : true
},
"Total game time" : {
"comment" : "Label for a stat row displaying the total game time.",
"isCommentAutoGenerated" : true
},
"Total Winnings" : { "Total Winnings" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -6927,6 +6984,7 @@
} }
}, },
"Win Rate" : { "Win Rate" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -6995,6 +7053,7 @@
} }
}, },
"Wins" : { "Wins" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -7016,7 +7075,12 @@
} }
} }
}, },
"Won hands" : {
"comment" : "Label for a circle that represents the number of hands the user has won in a statistics sheet.",
"isCommentAutoGenerated" : true
},
"Worst" : { "Worst" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -7038,6 +7102,10 @@
} }
} }
}, },
"Worst loss" : {
"comment" : "Description of a chip stat row when displaying the worst loss.",
"isCommentAutoGenerated" : true
},
"Yes ($%lld)" : { "Yes ($%lld)" : {
"localizations" : { "localizations" : {
"en" : { "en" : {

View File

@ -11,9 +11,28 @@ import CasinoKit
/// Saved round result for history. /// Saved round result for history.
struct SavedRoundResult: Codable, Equatable { struct SavedRoundResult: Codable, Equatable {
let date: Date let date: Date
let gameStyle: String // "vegas", "atlantic", "european", "custom"
let mainResult: String // "blackjack", "win", "lose", "push", "bust", "surrender" let mainResult: String // "blackjack", "win", "lose", "push", "bust", "surrender"
let hadSplit: Bool let hadSplit: Bool
let totalWinnings: Int let totalWinnings: Int
let roundDuration: TimeInterval // Duration in seconds
}
/// Per-style statistics for tracking.
struct StyleStatistics: Codable, Equatable {
var roundsPlayed: Int = 0
var wins: Int = 0
var losses: Int = 0
var pushes: Int = 0
var blackjacks: Int = 0
var busts: Int = 0
var surrenders: Int = 0
var totalWinnings: Int = 0
var biggestWin: Int = 0
var biggestLoss: Int = 0
var totalPlayTime: TimeInterval = 0 // Cumulative seconds
var totalBetAmount: Int = 0
var biggestBet: Int = 0
} }
/// Persistent game data that syncs to iCloud. /// Persistent game data that syncs to iCloud.
@ -28,21 +47,29 @@ struct BlackjackGameData: PersistableGameData {
lastModified: Date(), lastModified: Date(),
balance: 10_000, balance: 10_000,
roundHistory: [], roundHistory: [],
styleStats: [:],
totalWinnings: 0, totalWinnings: 0,
biggestWin: 0, biggestWin: 0,
biggestLoss: 0, biggestLoss: 0,
blackjackCount: 0, blackjackCount: 0,
bustCount: 0 bustCount: 0,
totalPlayTime: 0
) )
} }
var balance: Int var balance: Int
var roundHistory: [SavedRoundResult] var roundHistory: [SavedRoundResult]
/// Per-style statistics keyed by style rawValue.
var styleStats: [String: StyleStatistics]
// Legacy global stats (kept for backward compatibility)
var totalWinnings: Int var totalWinnings: Int
var biggestWin: Int var biggestWin: Int
var biggestLoss: Int var biggestLoss: Int
var blackjackCount: Int var blackjackCount: Int
var bustCount: Int var bustCount: Int
var totalPlayTime: TimeInterval
} }
/// Persistent settings data that syncs to iCloud. /// Persistent settings data that syncs to iCloud.

View File

@ -20,7 +20,7 @@ enum Design {
static let showDebugBorders = false static let showDebugBorders = false
/// Set to true to show debug log statements /// Set to true to show debug log statements
static let showDebugLogs = false static let showDebugLogs = true
/// Debug logger - only prints when showDebugLogs is true /// Debug logger - only prints when showDebugLogs is true
static func debugLog(_ message: String) { static func debugLog(_ message: String) {

View File

@ -2,7 +2,7 @@
// StatisticsSheetView.swift // StatisticsSheetView.swift
// Blackjack // Blackjack
// //
// Game statistics and history. // Game statistics with Global and per-style tabs.
// //
import SwiftUI import SwiftUI
@ -12,217 +12,478 @@ struct StatisticsSheetView: View {
let state: GameState let state: GameState
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var selectedPage: Int = 0
// MARK: - Computed Stats /// All available statistics pages (Global + each style).
private var pages: [StatisticsPage] {
private var totalRounds: Int { var result: [StatisticsPage] = [
state.roundHistory.count .global(computeGlobalStats())
]
// Add pages for each style that has been played
for style in BlackjackStyle.allCases where style != .custom {
if let stats = state.styleStats[style.rawValue], stats.roundsPlayed > 0 {
result.append(.style(style, stats))
}
}
// Add custom if it has been played
if let customStats = state.styleStats[BlackjackStyle.custom.rawValue], customStats.roundsPlayed > 0 {
result.append(.style(.custom, customStats))
}
return result
} }
private var wins: Int { /// Computes aggregated global statistics from all styles.
state.roundHistory.filter { $0.mainHandResult.isWin }.count private func computeGlobalStats() -> StyleStatistics {
} var global = StyleStatistics()
private var losses: Int { for (_, stats) in state.styleStats {
state.roundHistory.filter { global.roundsPlayed += stats.roundsPlayed
$0.mainHandResult == .lose || $0.mainHandResult == .bust global.wins += stats.wins
}.count global.losses += stats.losses
} global.pushes += stats.pushes
global.blackjacks += stats.blackjacks
private var pushes: Int { global.busts += stats.busts
state.roundHistory.filter { $0.mainHandResult == .push }.count global.surrenders += stats.surrenders
} global.totalWinnings += stats.totalWinnings
global.totalPlayTime += stats.totalPlayTime
private var blackjacks: Int { global.totalBetAmount += stats.totalBetAmount
state.roundHistory.filter { $0.mainHandResult == .blackjack }.count
} if stats.biggestWin > global.biggestWin {
global.biggestWin = stats.biggestWin
private var busts: Int { }
state.roundHistory.filter { $0.mainHandResult == .bust }.count if stats.biggestLoss < global.biggestLoss {
} global.biggestLoss = stats.biggestLoss
}
private var surrenders: Int { if stats.biggestBet > global.biggestBet {
state.roundHistory.filter { $0.mainHandResult == .surrender }.count global.biggestBet = stats.biggestBet
} }
}
private var winRate: Double {
guard totalRounds > 0 else { return 0 } // If no style stats exist yet, use session data
return Double(wins) / Double(totalRounds) * 100 if global.roundsPlayed == 0 {
} global.roundsPlayed = state.roundHistory.count
global.wins = state.roundHistory.filter { $0.mainHandResult.isWin }.count
private var totalWinnings: Int { global.losses = state.roundHistory.filter { $0.mainHandResult == .lose || $0.mainHandResult == .bust }.count
state.roundHistory.reduce(0) { $0 + $1.totalWinnings } global.pushes = state.roundHistory.filter { $0.mainHandResult == .push }.count
} global.blackjacks = state.roundHistory.filter { $0.mainHandResult == .blackjack }.count
global.busts = state.roundHistory.filter { $0.mainHandResult == .bust }.count
private var biggestWin: Int { global.surrenders = state.roundHistory.filter { $0.mainHandResult == .surrender }.count
state.roundHistory.map { $0.totalWinnings }.filter { $0 > 0 }.max() ?? 0 global.totalWinnings = state.roundHistory.reduce(0) { $0 + $1.totalWinnings }
} global.totalPlayTime = state.totalPlayTime
}
private var biggestLoss: Int {
state.roundHistory.map { $0.totalWinnings }.filter { $0 < 0 }.min() ?? 0 return global
} }
var body: some View { var body: some View {
SheetContainerView( SheetContainerView(
title: String(localized: "Statistics"), title: String(localized: "Statistics"),
content: { content: {
// Session Summary VStack(spacing: Design.Spacing.medium) {
SheetSection(title: String(localized: "SESSION SUMMARY"), icon: "chart.bar.fill") { // Page selector with current style header
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: Design.Spacing.medium) { pageHeader
StatBox(title: String(localized: "Rounds"), value: "\(totalRounds)", color: .white)
StatBox(title: String(localized: "Win Rate"), value: formatPercent(winRate), color: winRate >= 50 ? .green : .orange) // Page indicator dots
StatBox(title: String(localized: "Net"), value: formatMoney(totalWinnings), color: totalWinnings >= 0 ? .green : .red) pageIndicator
StatBox(title: String(localized: "Balance"), value: "$\(state.balance)", color: Color.Sheet.accent)
}
} }
// Win Distribution // Current page content
SheetSection(title: String(localized: "OUTCOMES"), icon: "chart.pie.fill") { if selectedPage < pages.count {
VStack(spacing: Design.Spacing.small) { statisticsContent(for: pages[selectedPage])
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)
}
}
} }
}, },
onCancel: nil, onCancel: nil,
onDone: { dismiss() }, onDone: { dismiss() },
doneButtonText: String(localized: "Done") doneButtonText: String(localized: "Done")
) )
.gesture(
DragGesture()
.onEnded { value in
let threshold: CGFloat = 50
if value.translation.width < -threshold && selectedPage < pages.count - 1 {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
selectedPage += 1
}
} else if value.translation.width > threshold && selectedPage > 0 {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
selectedPage -= 1
}
}
}
)
} }
// MARK: - Page Header
@ViewBuilder
private var pageHeader: some View {
let currentPage = selectedPage < pages.count ? pages[selectedPage] : .global(StyleStatistics())
HStack(spacing: Design.Spacing.medium) {
// Left arrow
Button {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
if selectedPage > 0 {
selectedPage -= 1
}
}
} label: {
Image(systemName: "chevron.left")
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
.foregroundStyle(selectedPage > 0 ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.hint))
}
.disabled(selectedPage == 0)
Spacer()
// Page title with icon
VStack(spacing: Design.Spacing.xSmall) {
Image(systemName: currentPage.icon)
.font(.system(size: Design.BaseFontSize.xxLarge))
.foregroundStyle(currentPage.accentColor)
Text(currentPage.title)
.font(.system(size: Design.BaseFontSize.title, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
Spacer()
// Right arrow
Button {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
if selectedPage < pages.count - 1 {
selectedPage += 1
}
}
} label: {
Image(systemName: "chevron.right")
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
.foregroundStyle(selectedPage < pages.count - 1 ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.hint))
}
.disabled(selectedPage >= pages.count - 1)
}
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.top, Design.Spacing.small)
}
// MARK: - Page Indicator
private var pageIndicator: some View {
HStack(spacing: Design.Spacing.small) {
ForEach(pages.indices, id: \.self) { index in
Circle()
.fill(index == selectedPage ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.light))
.frame(width: Design.Spacing.small, height: Design.Spacing.small)
.onTapGesture {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
selectedPage = index
}
}
}
}
}
// MARK: - Statistics Content
@ViewBuilder
private func statisticsContent(for page: StatisticsPage) -> some View {
let stats = page.statistics
// In-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("\(stats.roundsPlayed)")
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
// Win/Loss/Push distribution
HStack(spacing: Design.Spacing.medium) {
OutcomeCircle(
label: String(localized: "Won hands"),
count: stats.wins,
color: .white
)
OutcomeCircle(
label: String(localized: "Lost hands"),
count: stats.losses,
color: Color.red
)
OutcomeCircle(
label: String(localized: "Pushed hands"),
count: stats.pushes,
color: .gray
)
}
Divider()
.background(Color.white.opacity(Design.Opacity.hint))
// Game time and special outcomes
StatRow(icon: "clock", label: String(localized: "Total game time"), value: formatTime(stats.totalPlayTime))
StatRow(icon: "21.circle.fill", label: String(localized: "Blackjacks"), value: "\(stats.blackjacks)", valueColor: .yellow)
StatRow(icon: "flame.fill", label: String(localized: "Busts"), value: "\(stats.busts)", valueColor: .orange)
if stats.surrenders > 0 {
StatRow(icon: "flag.fill", label: String(localized: "Surrenders"), value: "\(stats.surrenders)", valueColor: .gray)
}
}
}
// Chips Stats section
SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") {
VStack(spacing: Design.Spacing.medium) {
ChipStatRow(
icon: "chart.line.uptrend.xyaxis",
iconColor: stats.totalWinnings >= 0 ? .green : .red,
label: String(localized: "Total gain"),
value: formatMoney(stats.totalWinnings)
)
ChipStatRow(
icon: "arrow.up.circle.fill",
iconColor: .green,
label: String(localized: "Best gain"),
value: formatMoney(stats.biggestWin)
)
ChipStatRow(
icon: "arrow.down.circle.fill",
iconColor: .red,
label: String(localized: "Worst loss"),
value: formatMoney(stats.biggestLoss)
)
Divider()
.background(Color.white.opacity(Design.Opacity.hint))
ChipStatRow(
icon: "plusminus.circle.fill",
iconColor: .blue,
label: String(localized: "Total bet"),
value: "$\(stats.totalBetAmount)"
)
if stats.roundsPlayed > 0 {
ChipStatRow(
icon: "equal.circle.fill",
iconColor: .purple,
label: String(localized: "Average bet"),
value: "$\(stats.totalBetAmount / stats.roundsPlayed)"
)
}
ChipStatRow(
icon: "star.circle.fill",
iconColor: .orange,
label: String(localized: "Biggest bet"),
value: "$\(stats.biggestBet)"
)
}
}
}
// MARK: - Formatters
private func formatMoney(_ amount: Int) -> String { private func formatMoney(_ amount: Int) -> String {
if amount >= 0 { if amount >= 0 {
return "+$\(amount)" return "$\(amount)"
} else { } else {
return "-$\(abs(amount))" return "-$\(abs(amount))"
} }
} }
private func formatPercent(_ value: Double) -> String { private func formatTime(_ seconds: TimeInterval) -> String {
value.formatted(.number.precision(.fractionLength(1))) + "%" let hours = Int(seconds) / 3600
let minutes = (Int(seconds) % 3600) / 60
return String(format: "%02dh %02dmin", hours, minutes)
} }
} }
// MARK: - Stat Box // MARK: - Statistics Page Type
struct StatBox: View { private enum StatisticsPage: Identifiable {
let title: String case global(StyleStatistics)
let value: String case style(BlackjackStyle, StyleStatistics)
var id: String {
switch self {
case .global:
return "global"
case .style(let style, _):
return style.rawValue
}
}
var title: String {
switch self {
case .global:
return String(localized: "GLOBAL")
case .style(let style, _):
return style.displayName.uppercased()
}
}
var icon: String {
switch self {
case .global:
return "globe"
case .style(let style, _):
switch style {
case .vegas:
return "building.2.fill"
case .atlantic:
return "water.waves"
case .european:
return "flag.fill"
case .custom:
return "slider.horizontal.3"
}
}
}
var accentColor: Color {
switch self {
case .global:
return Color.Sheet.accent
case .style(let style, _):
switch style {
case .vegas:
return .orange
case .atlantic:
return .cyan
case .european:
return .blue
case .custom:
return .purple
}
}
}
var statistics: StyleStatistics {
switch self {
case .global(let stats):
return stats
case .style(_, let stats):
return stats
}
}
}
// MARK: - Supporting Views
private struct OutcomeCircle: View {
let label: String
let count: Int
let color: Color let color: Color
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.xSmall) { VStack(spacing: Design.Spacing.small) {
Text(title) Text(label)
.font(.system(size: Design.BaseFontSize.small)) .font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
Text(value) Text("\(count)")
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded)) .font(.system(size: Design.BaseFontSize.large, weight: .bold))
.foregroundStyle(color) .foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7) ZStack {
Circle()
.fill(Color.black.opacity(Design.Opacity.light))
.frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize)
Circle()
.stroke(color, lineWidth: Design.LineWidth.thick)
.frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize)
// Inner filled circle
Circle()
.fill(color.opacity(Design.Opacity.medium))
.frame(width: Size.outcomeCircleInner, height: Size.outcomeCircleInner)
}
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.white.opacity(Design.Opacity.subtle))
)
} }
} }
// MARK: - Outcome Row private struct StatRow: View {
let icon: String
struct OutcomeRow: View {
let label: String let label: String
let count: Int let value: String
let total: Int var valueColor: Color = .white
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 { var body: some View {
HStack { HStack {
// Label Image(systemName: icon)
.font(.system(size: Design.BaseFontSize.large))
.foregroundStyle(Color.Sheet.accent)
.frame(width: Size.statIconWidth)
Text(label) Text(label)
.font(.system(size: Design.BaseFontSize.body)) .font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.strong)) .foregroundStyle(.white.opacity(Design.Opacity.strong))
Spacer() Spacer()
// Count Text(value)
Text("\(count)") .font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
.font(.system(size: Design.BaseFontSize.body, weight: .bold)) .foregroundStyle(valueColor)
.foregroundStyle(color)
// Progress bar
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
.fill(Color.white.opacity(Design.Opacity.subtle))
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
.fill(color)
.frame(width: geometry.size.width * CGFloat(percentage / 100))
}
}
.frame(width: 60, height: 8)
// Percentage
Text(formatPercentWhole(percentage))
.font(.system(size: Design.BaseFontSize.small, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
.frame(width: 40, alignment: .trailing)
} }
.padding(.vertical, Design.Spacing.xSmall)
} }
} }
private struct ChipStatRow: View {
let icon: String
let iconColor: Color
let label: String
let value: String
var body: some View {
HStack {
// Chip-style icon
ZStack {
Circle()
.fill(iconColor)
.frame(width: Size.chipIconSize, height: Size.chipIconSize)
Image(systemName: icon)
.font(.system(size: Design.BaseFontSize.small, weight: .bold))
.foregroundStyle(.white)
}
Text(label)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
Spacer()
Text(value)
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
.foregroundStyle(Color.Sheet.accent)
}
}
}
// MARK: - Local Size Constants
private enum Size {
static let outcomeCircleSize: CGFloat = 48
static let outcomeCircleInner: CGFloat = 24
static let statIconWidth: CGFloat = 32
static let chipIconSize: CGFloat = 28
}
// MARK: - Preview
#Preview { #Preview {
StatisticsSheetView(state: GameState(settings: GameSettings())) StatisticsSheetView(state: GameState(settings: GameSettings()))
} }

View File

@ -119,7 +119,7 @@ struct BlackjackTableView: View {
// Player hands area - only show when there are cards dealt // Player hands area - only show when there are cards dealt
if state.playerHands.first?.cards.isEmpty == false { if state.playerHands.first?.cards.isEmpty == false {
ZStack { ZStack {
PlayerHandsView( PlayerHandsContainer(
hands: state.playerHands, hands: state.playerHands,
activeHandIndex: state.activeHandIndex, activeHandIndex: state.activeHandIndex,
isPlayerTurn: state.isPlayerTurn, isPlayerTurn: state.isPlayerTurn,

View File

@ -0,0 +1,198 @@
//
// 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
/// Scaled animation duration based on dealing speed.
private var animationDuration: Double {
Design.Animation.springDuration * dealingSpeed
}
var body: some View {
HStack(spacing: cards.isEmpty ? Design.Spacing.small : cardSpacing) {
if cards.isEmpty {
CardPlaceholderView(width: cardWidth)
CardPlaceholderView(width: cardWidth)
} else {
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
)
}
}
}
.animation(
showAnimations
? .spring(duration: animationDuration, bounce: Design.Animation.springBounce)
: .none,
value: cards.count
)
}
}
// MARK: - Convenience Initializers
extension CardStackView {
/// Creates a card stack for the dealer (with hole card support).
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
)
)
}
/// Creates a card stack for the player (all cards face up).
static func player(
cards: [Card],
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: { _ in true },
dealOffset: CGPoint(
x: Design.DealAnimation.playerOffsetX,
y: Design.DealAnimation.playerOffsetY
)
)
}
}
// MARK: - Previews
#Preview("Empty") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView(
cards: [],
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: false,
isFaceUp: { _ in true },
dealOffset: .zero
)
}
}
#Preview("Player Cards") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView.player(
cards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .spades, rank: .king)
],
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: true
)
}
}
#Preview("Dealer - Hole Hidden") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView.dealer(
cards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .spades, rank: .king)
],
showHoleCard: false,
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: true
)
}
}
#Preview("Dealer - Hole Revealed") {
ZStack {
Color.Table.felt.ignoresSafeArea()
CardStackView.dealer(
cards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .spades, rank: .king)
],
showHoleCard: true,
cardWidth: 60,
cardSpacing: -20,
showAnimations: true,
dealingSpeed: 1.0,
showCardCount: true
)
}
}

View File

@ -23,11 +23,6 @@ struct DealerHandView: View {
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
@ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge @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
}
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.small) { VStack(spacing: Design.Spacing.small) {
@ -39,17 +34,14 @@ struct DealerHandView: View {
// Calculate value from visible cards only // Calculate value from visible cards only
if !hand.cards.isEmpty && visibleCardCount > 0 { if !hand.cards.isEmpty && visibleCardCount > 0 {
if showHoleCard && visibleCardCount >= hand.cards.count { if showHoleCard {
// All cards visible - calculate total hand value from visible cards // Hole card revealed - calculate value from visible cards
let visibleCards = Array(hand.cards.prefix(visibleCardCount)) let visibleCards = Array(hand.cards.prefix(visibleCardCount))
let visibleValue = visibleCards.reduce(0) { $0 + $1.blackjackValue } let displayValue = BlackjackHand.bestValue(for: visibleCards)
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) ValueBadge(value: displayValue, color: Color.Hand.dealer)
.animation(nil, value: displayValue) // No animation when value changes .animation(nil, value: displayValue) // No animation when value changes
} else if visibleCardCount >= 1 { } else {
// Hole card hidden or not all cards visible - show only the first (face-up) card's value // Hole card hidden - show only the first (face-up) card's value
ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer) ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer)
.animation(nil, value: hand.cards[0].blackjackValue) // No animation when value changes .animation(nil, value: hand.cards[0].blackjackValue) // No animation when value changes
} }
@ -61,48 +53,22 @@ struct DealerHandView: View {
.animation(nil, value: showHoleCard) .animation(nil, value: showHoleCard)
// Cards with result badge overlay (overlay prevents height change) // Cards with result badge overlay (overlay prevents height change)
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) { HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
if hand.cards.isEmpty { CardStackView.dealer(
cards: hand.cards,
showHoleCard: showHoleCard,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
showCardCount: showCardCount
)
// Show placeholder for second card in European mode (no hole card)
if hand.cards.count == 1 && !showHoleCard {
CardPlaceholderView(width: cardWidth) CardPlaceholderView(width: cardWidth)
CardPlaceholderView(width: cardWidth) .opacity(Design.Opacity.medium)
} 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
)
.overlay { .overlay {
// Result badge - centered on cards // Result badge - centered on cards
if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil { if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil {

View File

@ -2,103 +2,12 @@
// PlayerHandView.swift // PlayerHandView.swift
// Blackjack // Blackjack
// //
// Displays player hands in a horizontally scrollable container. // Displays a single player hand with cards, value, bet, and result.
// //
import SwiftUI import SwiftUI
import CasinoKit 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. /// Displays a single player hand with cards, value, and result.
struct PlayerHandView: View { struct PlayerHandView: View {
let hand: BlackjackHand let hand: BlackjackHand
@ -119,55 +28,52 @@ struct PlayerHandView: View {
/// Whether the hint toast should be visible. /// Whether the hint toast should be visible.
let showHintToast: Bool 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: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
@ScaledMetric(relativeTo: .caption) private var handNumberSize: CGFloat = Design.Size.handNumberFontSize @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 iconSize: CGFloat = Design.Size.handIconSize
@ScaledMetric(relativeTo: .body) private var hintPaddingH: CGFloat = Design.Size.hintPaddingH @ScaledMetric(relativeTo: .body) private var hintPaddingH: CGFloat = Design.Size.hintPaddingH
@ScaledMetric(relativeTo: .body) private var hintPaddingV: CGFloat = Design.Size.hintPaddingV @ScaledMetric(relativeTo: .body) private var hintPaddingV: CGFloat = Design.Size.hintPaddingV
/// Calculates display info for visible cards using shared BlackjackHand logic.
private var visibleCardsDisplayInfo: (text: String, color: Color)? {
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)
// Determine color based on visible cards
let isVisibleBlackjack = visibleCards.count == 2 && displayValue == 21 && !hand.isSplit
let isVisibleBusted = hardValue > 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 (e.g., "8/18" for soft hands)
let valueText = hasSoftAce ? "\(hardValue)/\(softValue)" : "\(displayValue)"
return (valueText, displayColor)
}
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.small) { VStack(spacing: Design.Spacing.small) {
// Cards with container // Cards with container
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) { CardStackView.player(
if hand.cards.isEmpty { cards: hand.cards,
CardPlaceholderView(width: cardWidth) cardWidth: cardWidth,
CardPlaceholderView(width: cardWidth) cardSpacing: cardSpacing,
} else { showAnimations: showAnimations,
ForEach(hand.cards.indices, id: \.self) { index in dealingSpeed: dealingSpeed,
CardView( showCardCount: showCardCount
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
) )
.padding(.horizontal, Design.Spacing.medium) .padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.medium) .padding(.vertical, Design.Spacing.medium)
@ -214,35 +120,13 @@ struct PlayerHandView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
} }
// Calculate value from visible (animation-completed) cards // Calculate value from visible (animation-completed) cards only
// Always show the value - it updates as cards become visible if let displayInfo = visibleCardsDisplayInfo {
if !hand.cards.isEmpty { Text(displayInfo.text)
// Use only the cards that have completed their animation
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
let visibleValue = visibleCards.reduce(0) { $0 + $1.blackjackValue }
let visibleHasSoftAce = visibleCards.contains { $0.rank == .ace } && visibleValue + 10 <= 21
let displayValue = visibleHasSoftAce && visibleValue + 10 <= 21 ? visibleValue + 10 : visibleValue
// Determine color based on visible cards
let isVisibleBlackjack = visibleCards.count == 2 && displayValue == 21
let isVisibleBusted = visibleValue > 21
let isVisible21 = displayValue == 21 && !isVisibleBlackjack
let displayColor: Color = {
if isVisibleBlackjack { return .yellow }
if isVisibleBusted { return .red }
if isVisible21 { return .green }
return .white
}()
// Show value like hand.valueDisplay does
let valueText = visibleHasSoftAce ? "\(visibleValue)/\(displayValue)" : "\(displayValue)"
Text(valueText)
.font(.system(size: labelFontSize, weight: .bold, design: .rounded)) .font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(displayColor) .foregroundStyle(displayInfo.color)
.animation(nil, value: valueText) // No animation when text changes .animation(nil, value: displayInfo.text)
.animation(nil, value: displayColor) // No animation when color changes .animation(nil, value: displayInfo.color)
} }
if hand.isDoubledDown { if hand.isDoubledDown {
@ -289,80 +173,87 @@ struct PlayerHandView: View {
// MARK: - Previews // MARK: - Previews
#Preview("Single Hand - Empty") { #Preview("Empty Hand") {
ZStack { ZStack {
Color.Table.felt.ignoresSafeArea() Color.Table.felt.ignoresSafeArea()
PlayerHandsView( PlayerHandView(
hands: [BlackjackHand()], hand: BlackjackHand(),
activeHandIndex: 0, isActive: true,
isPlayerTurn: true,
showCardCount: false, showCardCount: false,
showAnimations: true, showAnimations: true,
dealingSpeed: 1.0, dealingSpeed: 1.0,
handNumber: nil,
cardWidth: 60, cardWidth: 60,
cardSpacing: -20, cardSpacing: -20,
visibleCardCounts: [0], visibleCardCount: 0,
currentHint: nil, currentHint: nil,
showHintToast: false showHintToast: false
) )
} }
} }
#Preview("Single Hand - Cards") { #Preview("With Cards") {
ZStack { ZStack {
Color.Table.felt.ignoresSafeArea() Color.Table.felt.ignoresSafeArea()
PlayerHandsView( PlayerHandView(
hands: [BlackjackHand(cards: [ hand: BlackjackHand(cards: [
Card(suit: .clubs, rank: .eight), Card(suit: .clubs, rank: .eight),
Card(suit: .hearts, rank: .nine) Card(suit: .hearts, rank: .nine)
], bet: 100)], ], bet: 100),
activeHandIndex: 0, isActive: true,
isPlayerTurn: true,
showCardCount: false, showCardCount: false,
showAnimations: true, showAnimations: true,
dealingSpeed: 1.0, dealingSpeed: 1.0,
handNumber: nil,
cardWidth: 60, cardWidth: 60,
cardSpacing: -20, cardSpacing: -20,
visibleCardCounts: [2], visibleCardCount: 2,
currentHint: "Hit", currentHint: "Hit",
showHintToast: true showHintToast: true
) )
} }
} }
#Preview("Split Hands") { #Preview("Blackjack") {
ZStack { ZStack {
Color.Table.felt.ignoresSafeArea() Color.Table.felt.ignoresSafeArea()
PlayerHandsView( PlayerHandView(
hands: [ hand: BlackjackHand(cards: [
BlackjackHand(cards: [ Card(suit: .hearts, rank: .ace),
Card(suit: .clubs, rank: .eight), Card(suit: .spades, rank: .king)
Card(suit: .spades, rank: .jack) ], bet: 100),
], bet: 100), isActive: false,
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,
showCardCount: true, showCardCount: true,
showAnimations: true, showAnimations: true,
dealingSpeed: 1.0, dealingSpeed: 1.0,
handNumber: nil,
cardWidth: 60, cardWidth: 60,
cardSpacing: -20, cardSpacing: -20,
visibleCardCounts: [2, 2, 2, 2], visibleCardCount: 2,
currentHint: nil,
showHintToast: false
)
}
}
#Preview("Split Hand") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerHandView(
hand: BlackjackHand(cards: [
Card(suit: .clubs, rank: .eight),
Card(suit: .spades, rank: .jack)
], bet: 100),
isActive: true,
showCardCount: false,
showAnimations: true,
dealingSpeed: 1.0,
handNumber: 2,
cardWidth: 60,
cardSpacing: -20,
visibleCardCount: 2,
currentHint: "Stand", currentHint: "Stand",
showHintToast: true showHintToast: true
) )
} }
} }

View File

@ -0,0 +1,148 @@
//
// 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
/// 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)
}
.scrollClipDisabled()
.scrollBounceBehavior(.always)
.defaultScrollAnchor(.center)
.onChange(of: activeHandIndex) { _, _ in
scrollToActiveHand(proxy: proxy)
}
.onChange(of: totalCardCount) { _, _ in
scrollToActiveHand(proxy: proxy)
}
.onChange(of: hands.count) { _, _ in
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: - 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
)
}
}