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 blackjackCount: 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
@ -336,6 +348,9 @@ final class GameState {
self.persistence = CloudSyncManager<BlackjackGameData>()
syncSoundSettings()
loadSavedGame()
// Start timing for the first round's betting phase
roundStartTime = Date()
}
/// Syncs sound settings with SoundManager.
@ -361,6 +376,15 @@ final class GameState {
self.biggestLoss = data.biggestLoss
self.blackjackCount = data.blackjackCount
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
persistence.onCloudDataReceived = { [weak self] newData in
@ -371,17 +395,23 @@ final class GameState {
self.biggestLoss = newData.biggestLoss
self.blackjackCount = newData.blackjackCount
self.bustCount = newData.bustCount
self.totalPlayTime = newData.totalPlayTime
self.styleStats = newData.styleStats
}
}
/// Saves current game data to iCloud and local storage.
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
SavedRoundResult(
date: Date(),
gameStyle: settings.gameStyle.rawValue,
mainResult: result.mainHandResult.saveName,
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(),
balance: balance,
roundHistory: savedRounds,
styleStats: styleStats,
totalWinnings: totalWinnings,
biggestWin: biggestWin,
biggestLoss: biggestLoss,
blackjackCount: blackjackCount,
bustCount: bustCount
bustCount: bustCount,
totalPlayTime: totalPlayTime
)
persistence.save(data)
}
@ -407,7 +439,11 @@ final class GameState {
biggestLoss = 0
blackjackCount = 0
bustCount = 0
totalPlayTime = 0
styleStats = [:]
roundHistory = []
roundStartTime = nil
roundBetAmount = 0
newRound()
}
@ -467,6 +503,10 @@ final class GameState {
func deal() async {
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
if !engine.canDealNewHand {
engine.reshuffle()
@ -1022,7 +1062,20 @@ final class GameState {
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
if roundWinnings > biggestWin {
biggestWin = roundWinnings
@ -1037,6 +1090,46 @@ final class GameState {
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
let allHandResults = playerHands.map { $0.result ?? .lose }
@ -1131,6 +1224,10 @@ final class GameState {
twentyOnePlusThreeResult = nil
showSideBetToasts = false
// Start timing for the new round (includes betting phase)
roundStartTime = Date()
Design.debugLog("🎰 New round started - roundStartTime: \(roundStartTime!)")
// Reset UI state
showResultBanner = false
lastRoundResult = nil

View File

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

View File

@ -1080,6 +1080,10 @@
}
}
},
"Average bet" : {
"comment" : "Label for the average bet value in the Statistics Sheet.",
"isCommentAutoGenerated" : true
},
"Baccarat" : {
"comment" : "The name of a casino game.",
"isCommentAutoGenerated" : true,
@ -1105,6 +1109,7 @@
}
},
"Balance" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -1198,6 +1203,7 @@
}
},
"Best" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"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" : {
"comment" : "Betting recommendation based on a true count of 1.",
"isCommentAutoGenerated" : true,
@ -1482,7 +1492,12 @@
}
}
},
"Biggest bet" : {
"comment" : "Label for the \"Biggest bet\" row in the Statistics Sheet.",
"isCommentAutoGenerated" : true
},
"BIGGEST SWINGS" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -1865,6 +1880,10 @@
},
"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" : {
"localizations" : {
@ -3410,6 +3429,10 @@
},
"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" : {
"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" : {
"localizations" : {
"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." : {
"localizations" : {
"en" : {
@ -4164,6 +4195,7 @@
}
},
"Losses" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"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" : {
"comment" : "Description of a deck count option when the user selects 2 decks.",
"isCommentAutoGenerated" : true,
@ -4438,6 +4474,7 @@
}
},
"Net" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -4826,6 +4863,7 @@
}
},
"OUTCOMES" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -5193,7 +5231,12 @@
}
}
},
"Pushed hands" : {
"comment" : "Label for the \"Pushed hands\" outcome circle in the statistics sheet.",
"isCommentAutoGenerated" : true
},
"Pushes" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -5399,6 +5442,7 @@
}
},
"Rounds" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -5558,6 +5602,7 @@
},
"SESSION SUMMARY" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"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" : {
"localizations" : {
"en" : {
@ -6927,6 +6984,7 @@
}
},
"Win Rate" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -6995,6 +7053,7 @@
}
},
"Wins" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"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" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -7038,6 +7102,10 @@
}
}
},
"Worst loss" : {
"comment" : "Description of a chip stat row when displaying the worst loss.",
"isCommentAutoGenerated" : true
},
"Yes ($%lld)" : {
"localizations" : {
"en" : {

View File

@ -11,9 +11,28 @@ import CasinoKit
/// Saved round result for history.
struct SavedRoundResult: Codable, Equatable {
let date: Date
let gameStyle: String // "vegas", "atlantic", "european", "custom"
let mainResult: String // "blackjack", "win", "lose", "push", "bust", "surrender"
let hadSplit: Bool
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.
@ -28,21 +47,29 @@ struct BlackjackGameData: PersistableGameData {
lastModified: Date(),
balance: 10_000,
roundHistory: [],
styleStats: [:],
totalWinnings: 0,
biggestWin: 0,
biggestLoss: 0,
blackjackCount: 0,
bustCount: 0
bustCount: 0,
totalPlayTime: 0
)
}
var balance: Int
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 biggestWin: Int
var biggestLoss: Int
var blackjackCount: Int
var bustCount: Int
var totalPlayTime: TimeInterval
}
/// Persistent settings data that syncs to iCloud.

View File

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

View File

@ -2,7 +2,7 @@
// StatisticsSheetView.swift
// Blackjack
//
// Game statistics and history.
// Game statistics with Global and per-style tabs.
//
import SwiftUI
@ -12,217 +12,478 @@ struct StatisticsSheetView: View {
let state: GameState
@Environment(\.dismiss) private var dismiss
@State private var selectedPage: Int = 0
// MARK: - Computed Stats
private var totalRounds: Int {
state.roundHistory.count
/// All available statistics pages (Global + each style).
private var pages: [StatisticsPage] {
var result: [StatisticsPage] = [
.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 {
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
/// Computes aggregated global statistics from all styles.
private func computeGlobalStats() -> StyleStatistics {
var global = StyleStatistics()
for (_, stats) in state.styleStats {
global.roundsPlayed += stats.roundsPlayed
global.wins += stats.wins
global.losses += stats.losses
global.pushes += stats.pushes
global.blackjacks += stats.blackjacks
global.busts += stats.busts
global.surrenders += stats.surrenders
global.totalWinnings += stats.totalWinnings
global.totalPlayTime += stats.totalPlayTime
global.totalBetAmount += stats.totalBetAmount
if stats.biggestWin > global.biggestWin {
global.biggestWin = stats.biggestWin
}
if stats.biggestLoss < global.biggestLoss {
global.biggestLoss = stats.biggestLoss
}
if stats.biggestBet > global.biggestBet {
global.biggestBet = stats.biggestBet
}
}
// If no style stats exist yet, use session data
if global.roundsPlayed == 0 {
global.roundsPlayed = state.roundHistory.count
global.wins = state.roundHistory.filter { $0.mainHandResult.isWin }.count
global.losses = state.roundHistory.filter { $0.mainHandResult == .lose || $0.mainHandResult == .bust }.count
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
global.surrenders = state.roundHistory.filter { $0.mainHandResult == .surrender }.count
global.totalWinnings = state.roundHistory.reduce(0) { $0 + $1.totalWinnings }
global.totalPlayTime = state.totalPlayTime
}
return global
}
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)
}
VStack(spacing: Design.Spacing.medium) {
// Page selector with current style header
pageHeader
// Page indicator dots
pageIndicator
}
// 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)
}
}
// Current page content
if selectedPage < pages.count {
statisticsContent(for: pages[selectedPage])
}
},
onCancel: nil,
onDone: { dismiss() },
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 {
if amount >= 0 {
return "+$\(amount)"
return "$\(amount)"
} else {
return "-$\(abs(amount))"
}
}
private func formatPercent(_ value: Double) -> String {
value.formatted(.number.precision(.fractionLength(1))) + "%"
private func formatTime(_ seconds: TimeInterval) -> String {
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 {
let title: String
let value: String
private enum StatisticsPage: Identifiable {
case global(StyleStatistics)
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
var body: some View {
VStack(spacing: Design.Spacing.xSmall) {
Text(title)
VStack(spacing: Design.Spacing.small) {
Text(label)
.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)
Text("\(count)")
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
.foregroundStyle(.white)
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)
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.white.opacity(Design.Opacity.subtle))
)
}
}
// MARK: - Outcome Row
struct OutcomeRow: View {
private struct StatRow: View {
let icon: String
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))) + "%"
}
let value: String
var valueColor: Color = .white
var body: some View {
HStack {
// Label
Image(systemName: icon)
.font(.system(size: Design.BaseFontSize.large))
.foregroundStyle(Color.Sheet.accent)
.frame(width: Size.statIconWidth)
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))
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)
Text(value)
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
.foregroundStyle(valueColor)
}
.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 {
StatisticsSheetView(state: GameState(settings: GameSettings()))
}

View File

@ -119,7 +119,7 @@ struct BlackjackTableView: View {
// Player hands area - only show when there are cards dealt
if state.playerHands.first?.cards.isEmpty == false {
ZStack {
PlayerHandsView(
PlayerHandsContainer(
hands: state.playerHands,
activeHandIndex: state.activeHandIndex,
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 badgeHeight: CGFloat = CasinoDesign.Size.valueBadge
/// Scaled animation duration based on dealing speed.
private var animationDuration: Double {
Design.Animation.springDuration * dealingSpeed
}
var body: some View {
VStack(spacing: Design.Spacing.small) {
@ -39,17 +34,14 @@ struct DealerHandView: View {
// 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
if showHoleCard {
// Hole card revealed - calculate 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
let displayValue = BlackjackHand.bestValue(for: visibleCards)
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
} else {
// Hole card hidden - 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
}
@ -61,48 +53,22 @@ struct DealerHandView: View {
.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 {
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)
} 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)
}
.opacity(Design.Opacity.medium)
}
}
.animation(
showAnimations
? .spring(duration: animationDuration, bounce: Design.Animation.springBounce)
: .none,
value: hand.cards.count
)
.overlay {
// Result badge - centered on cards
if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil {

View File

@ -2,103 +2,12 @@
// PlayerHandView.swift
// Blackjack
//
// Displays player hands in a horizontally scrollable container.
// Displays a single player hand with cards, value, bet, and result.
//
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,52 @@ 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
/// 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 {
VStack(spacing: Design.Spacing.small) {
// 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)
@ -214,35 +120,13 @@ struct PlayerHandView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
// Calculate value from visible (animation-completed) cards
// Always show the value - it updates as cards become visible
if !hand.cards.isEmpty {
// Use only the cards that have completed their animation
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
let visibleValue = visibleCards.reduce(0) { $0 + $1.blackjackValue }
let visibleHasSoftAce = visibleCards.contains { $0.rank == .ace } && visibleValue + 10 <= 21
let displayValue = visibleHasSoftAce && visibleValue + 10 <= 21 ? visibleValue + 10 : visibleValue
// Determine color based on visible cards
let isVisibleBlackjack = visibleCards.count == 2 && displayValue == 21
let isVisibleBusted = visibleValue > 21
let isVisible21 = displayValue == 21 && !isVisibleBlackjack
let displayColor: Color = {
if isVisibleBlackjack { return .yellow }
if isVisibleBusted { return .red }
if isVisible21 { return .green }
return .white
}()
// Show value like hand.valueDisplay does
let valueText = visibleHasSoftAce ? "\(visibleValue)/\(displayValue)" : "\(displayValue)"
Text(valueText)
// Calculate value from visible (animation-completed) cards only
if let displayInfo = visibleCardsDisplayInfo {
Text(displayInfo.text)
.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
.foregroundStyle(displayInfo.color)
.animation(nil, value: displayInfo.text)
.animation(nil, value: displayInfo.color)
}
if hand.isDoubledDown {
@ -289,80 +173,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") {
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("Blackjack") {
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: .king)
], bet: 100),
isActive: false,
showCardCount: true,
showAnimations: true,
dealingSpeed: 1.0,
handNumber: nil,
cardWidth: 60,
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",
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
)
}
}