Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
fd6e3355a5
commit
a9b4f95bb4
@ -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
|
||||
|
||||
@ -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 {
|
||||
@ -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" : {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()))
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
198
Blackjack/Blackjack/Views/Table/CardStackView.swift
Normal file
198
Blackjack/Blackjack/Views/Table/CardStackView.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
148
Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift
Normal file
148
Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user