diff --git a/Blackjack/Blackjack/Engine/GameState.swift b/Blackjack/Blackjack/Engine/GameState.swift index 058d97f..ad3a1d5 100644 --- a/Blackjack/Blackjack/Engine/GameState.swift +++ b/Blackjack/Blackjack/Engine/GameState.swift @@ -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() 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 diff --git a/Blackjack/Blackjack/Models/Hand.swift b/Blackjack/Blackjack/Models/BlackjackHand.swift similarity index 83% rename from Blackjack/Blackjack/Models/Hand.swift rename to Blackjack/Blackjack/Models/BlackjackHand.swift index a6ac652..bd2a30f 100644 --- a/Blackjack/Blackjack/Models/Hand.swift +++ b/Blackjack/Blackjack/Models/BlackjackHand.swift @@ -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 { diff --git a/Blackjack/Blackjack/Resources/Localizable.xcstrings b/Blackjack/Blackjack/Resources/Localizable.xcstrings index cb8cc6f..07dcaff 100644 --- a/Blackjack/Blackjack/Resources/Localizable.xcstrings +++ b/Blackjack/Blackjack/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Blackjack/Blackjack/Storage/BlackjackGameData.swift b/Blackjack/Blackjack/Storage/BlackjackGameData.swift index fca8fc4..2e4fdc6 100644 --- a/Blackjack/Blackjack/Storage/BlackjackGameData.swift +++ b/Blackjack/Blackjack/Storage/BlackjackGameData.swift @@ -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. diff --git a/Blackjack/Blackjack/Theme/DesignConstants.swift b/Blackjack/Blackjack/Theme/DesignConstants.swift index c87e9ed..3d99373 100644 --- a/Blackjack/Blackjack/Theme/DesignConstants.swift +++ b/Blackjack/Blackjack/Theme/DesignConstants.swift @@ -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) { diff --git a/Blackjack/Blackjack/Views/Sheets/StatisticsSheetView.swift b/Blackjack/Blackjack/Views/Sheets/StatisticsSheetView.swift index f4c86e0..1d47afc 100644 --- a/Blackjack/Blackjack/Views/Sheets/StatisticsSheetView.swift +++ b/Blackjack/Blackjack/Views/Sheets/StatisticsSheetView.swift @@ -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())) } - diff --git a/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift b/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift index 2ee9937..fd68f69 100644 --- a/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift +++ b/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift @@ -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, diff --git a/Blackjack/Blackjack/Views/Table/CardStackView.swift b/Blackjack/Blackjack/Views/Table/CardStackView.swift new file mode 100644 index 0000000..d7392b9 --- /dev/null +++ b/Blackjack/Blackjack/Views/Table/CardStackView.swift @@ -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 + ) + } +} + diff --git a/Blackjack/Blackjack/Views/Table/DealerHandView.swift b/Blackjack/Blackjack/Views/Table/DealerHandView.swift index a609bc1..0923913 100644 --- a/Blackjack/Blackjack/Views/Table/DealerHandView.swift +++ b/Blackjack/Blackjack/Views/Table/DealerHandView.swift @@ -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 { diff --git a/Blackjack/Blackjack/Views/Table/PlayerHandView.swift b/Blackjack/Blackjack/Views/Table/PlayerHandView.swift index 37bcdb3..2aa39cc 100644 --- a/Blackjack/Blackjack/Views/Table/PlayerHandView.swift +++ b/Blackjack/Blackjack/Views/Table/PlayerHandView.swift @@ -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 ) } } - diff --git a/Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift b/Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift new file mode 100644 index 0000000..b462323 --- /dev/null +++ b/Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift @@ -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 + ) + } +} +