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 biggestLoss: Int = 0
|
||||||
private(set) var blackjackCount: Int = 0
|
private(set) var blackjackCount: Int = 0
|
||||||
private(set) var bustCount: Int = 0
|
private(set) var bustCount: Int = 0
|
||||||
|
private(set) var totalPlayTime: TimeInterval = 0
|
||||||
|
|
||||||
|
/// Per-style statistics (keyed by style rawValue).
|
||||||
|
private(set) var styleStats: [String: StyleStatistics] = [:]
|
||||||
|
|
||||||
|
// MARK: - Round Timing
|
||||||
|
|
||||||
|
/// When the current round started (for duration tracking).
|
||||||
|
private var roundStartTime: Date?
|
||||||
|
|
||||||
|
/// The bet amount for the current round (tracked for stats).
|
||||||
|
private var roundBetAmount: Int = 0
|
||||||
|
|
||||||
// MARK: - Persistence
|
// MARK: - Persistence
|
||||||
|
|
||||||
@ -336,6 +348,9 @@ final class GameState {
|
|||||||
self.persistence = CloudSyncManager<BlackjackGameData>()
|
self.persistence = CloudSyncManager<BlackjackGameData>()
|
||||||
syncSoundSettings()
|
syncSoundSettings()
|
||||||
loadSavedGame()
|
loadSavedGame()
|
||||||
|
|
||||||
|
// Start timing for the first round's betting phase
|
||||||
|
roundStartTime = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Syncs sound settings with SoundManager.
|
/// Syncs sound settings with SoundManager.
|
||||||
@ -361,6 +376,15 @@ final class GameState {
|
|||||||
self.biggestLoss = data.biggestLoss
|
self.biggestLoss = data.biggestLoss
|
||||||
self.blackjackCount = data.blackjackCount
|
self.blackjackCount = data.blackjackCount
|
||||||
self.bustCount = data.bustCount
|
self.bustCount = data.bustCount
|
||||||
|
self.totalPlayTime = data.totalPlayTime
|
||||||
|
self.styleStats = data.styleStats
|
||||||
|
|
||||||
|
Design.debugLog("📂 Loaded game data:")
|
||||||
|
Design.debugLog(" - totalPlayTime: \(data.totalPlayTime) seconds")
|
||||||
|
Design.debugLog(" - styleStats keys: \(data.styleStats.keys.joined(separator: ", "))")
|
||||||
|
for (key, stats) in data.styleStats {
|
||||||
|
Design.debugLog(" - \(key): rounds=\(stats.roundsPlayed), time=\(stats.totalPlayTime)s, totalBet=\(stats.totalBetAmount)")
|
||||||
|
}
|
||||||
|
|
||||||
// Set up callback for when iCloud data arrives later
|
// Set up callback for when iCloud data arrives later
|
||||||
persistence.onCloudDataReceived = { [weak self] newData in
|
persistence.onCloudDataReceived = { [weak self] newData in
|
||||||
@ -371,17 +395,23 @@ final class GameState {
|
|||||||
self.biggestLoss = newData.biggestLoss
|
self.biggestLoss = newData.biggestLoss
|
||||||
self.blackjackCount = newData.blackjackCount
|
self.blackjackCount = newData.blackjackCount
|
||||||
self.bustCount = newData.bustCount
|
self.bustCount = newData.bustCount
|
||||||
|
self.totalPlayTime = newData.totalPlayTime
|
||||||
|
self.styleStats = newData.styleStats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves current game data to iCloud and local storage.
|
/// Saves current game data to iCloud and local storage.
|
||||||
private func saveGameData() {
|
private func saveGameData() {
|
||||||
|
// Note: savedRounds are reconstructed from roundHistory with current style
|
||||||
|
// The actual round data with style is stored during completeRound()
|
||||||
let savedRounds: [SavedRoundResult] = roundHistory.map { result in
|
let savedRounds: [SavedRoundResult] = roundHistory.map { result in
|
||||||
SavedRoundResult(
|
SavedRoundResult(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
|
gameStyle: settings.gameStyle.rawValue,
|
||||||
mainResult: result.mainHandResult.saveName,
|
mainResult: result.mainHandResult.saveName,
|
||||||
hadSplit: result.hadSplit,
|
hadSplit: result.hadSplit,
|
||||||
totalWinnings: result.totalWinnings
|
totalWinnings: result.totalWinnings,
|
||||||
|
roundDuration: 0 // Duration tracked separately in styleStats
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -389,11 +419,13 @@ final class GameState {
|
|||||||
lastModified: Date(),
|
lastModified: Date(),
|
||||||
balance: balance,
|
balance: balance,
|
||||||
roundHistory: savedRounds,
|
roundHistory: savedRounds,
|
||||||
|
styleStats: styleStats,
|
||||||
totalWinnings: totalWinnings,
|
totalWinnings: totalWinnings,
|
||||||
biggestWin: biggestWin,
|
biggestWin: biggestWin,
|
||||||
biggestLoss: biggestLoss,
|
biggestLoss: biggestLoss,
|
||||||
blackjackCount: blackjackCount,
|
blackjackCount: blackjackCount,
|
||||||
bustCount: bustCount
|
bustCount: bustCount,
|
||||||
|
totalPlayTime: totalPlayTime
|
||||||
)
|
)
|
||||||
persistence.save(data)
|
persistence.save(data)
|
||||||
}
|
}
|
||||||
@ -407,7 +439,11 @@ final class GameState {
|
|||||||
biggestLoss = 0
|
biggestLoss = 0
|
||||||
blackjackCount = 0
|
blackjackCount = 0
|
||||||
bustCount = 0
|
bustCount = 0
|
||||||
|
totalPlayTime = 0
|
||||||
|
styleStats = [:]
|
||||||
roundHistory = []
|
roundHistory = []
|
||||||
|
roundStartTime = nil
|
||||||
|
roundBetAmount = 0
|
||||||
newRound()
|
newRound()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -467,6 +503,10 @@ final class GameState {
|
|||||||
func deal() async {
|
func deal() async {
|
||||||
guard canDeal else { return }
|
guard canDeal else { return }
|
||||||
|
|
||||||
|
// Track bet amount for statistics (roundStartTime is set in newRound)
|
||||||
|
roundBetAmount = currentBet + perfectPairsBet + twentyOnePlusThreeBet
|
||||||
|
Design.debugLog("🎴 Deal started - roundStartTime: \(String(describing: roundStartTime)), roundBetAmount: \(roundBetAmount)")
|
||||||
|
|
||||||
// Ensure enough cards for a full hand - reshuffle if needed
|
// Ensure enough cards for a full hand - reshuffle if needed
|
||||||
if !engine.canDealNewHand {
|
if !engine.canDealNewHand {
|
||||||
engine.reshuffle()
|
engine.reshuffle()
|
||||||
@ -1022,7 +1062,20 @@ final class GameState {
|
|||||||
roundWinnings -= sideBetsTotal
|
roundWinnings -= sideBetsTotal
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update statistics
|
// Calculate round duration
|
||||||
|
let roundDuration: TimeInterval
|
||||||
|
if let startTime = roundStartTime {
|
||||||
|
roundDuration = Date().timeIntervalSince(startTime)
|
||||||
|
Design.debugLog("⏱️ Round duration: \(roundDuration) seconds (start: \(startTime))")
|
||||||
|
} else {
|
||||||
|
roundDuration = 0
|
||||||
|
Design.debugLog("⚠️ roundStartTime was nil - duration is 0")
|
||||||
|
}
|
||||||
|
totalPlayTime += roundDuration
|
||||||
|
roundStartTime = nil
|
||||||
|
Design.debugLog("⏱️ Total play time now: \(totalPlayTime) seconds")
|
||||||
|
|
||||||
|
// Update global statistics
|
||||||
totalWinnings += roundWinnings
|
totalWinnings += roundWinnings
|
||||||
if roundWinnings > biggestWin {
|
if roundWinnings > biggestWin {
|
||||||
biggestWin = roundWinnings
|
biggestWin = roundWinnings
|
||||||
@ -1037,6 +1090,46 @@ final class GameState {
|
|||||||
bustCount += 1
|
bustCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine if this round was a win, loss, push, or surrender for stats
|
||||||
|
let mainResult = playerHands.first?.result
|
||||||
|
let isWin = mainResult?.isWin ?? false
|
||||||
|
let isLoss = mainResult == .lose || mainResult == .bust
|
||||||
|
let isPush = mainResult == .push
|
||||||
|
let isSurrender = mainResult == .surrender
|
||||||
|
|
||||||
|
// Update per-style statistics
|
||||||
|
let styleKey = settings.gameStyle.rawValue
|
||||||
|
var stats = styleStats[styleKey] ?? StyleStatistics()
|
||||||
|
stats.roundsPlayed += 1
|
||||||
|
stats.totalPlayTime += roundDuration
|
||||||
|
stats.totalWinnings += roundWinnings
|
||||||
|
stats.totalBetAmount += roundBetAmount
|
||||||
|
|
||||||
|
Design.debugLog("📊 Style[\(styleKey)] stats update:")
|
||||||
|
Design.debugLog(" - roundBetAmount: \(roundBetAmount)")
|
||||||
|
Design.debugLog(" - stats.totalBetAmount: \(stats.totalBetAmount)")
|
||||||
|
Design.debugLog(" - stats.totalPlayTime: \(stats.totalPlayTime) seconds")
|
||||||
|
Design.debugLog(" - stats.roundsPlayed: \(stats.roundsPlayed)")
|
||||||
|
|
||||||
|
if roundWinnings > stats.biggestWin {
|
||||||
|
stats.biggestWin = roundWinnings
|
||||||
|
}
|
||||||
|
if roundWinnings < stats.biggestLoss {
|
||||||
|
stats.biggestLoss = roundWinnings
|
||||||
|
}
|
||||||
|
if roundBetAmount > stats.biggestBet {
|
||||||
|
stats.biggestBet = roundBetAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
if isWin { stats.wins += 1 }
|
||||||
|
if isLoss { stats.losses += 1 }
|
||||||
|
if isPush { stats.pushes += 1 }
|
||||||
|
if isSurrender { stats.surrenders += 1 }
|
||||||
|
if wasBlackjack { stats.blackjacks += 1 }
|
||||||
|
if hadBust { stats.busts += 1 }
|
||||||
|
|
||||||
|
styleStats[styleKey] = stats
|
||||||
|
|
||||||
// Create round result with all hand results, per-hand winnings, and side bets
|
// Create round result with all hand results, per-hand winnings, and side bets
|
||||||
let allHandResults = playerHands.map { $0.result ?? .lose }
|
let allHandResults = playerHands.map { $0.result ?? .lose }
|
||||||
|
|
||||||
@ -1131,6 +1224,10 @@ final class GameState {
|
|||||||
twentyOnePlusThreeResult = nil
|
twentyOnePlusThreeResult = nil
|
||||||
showSideBetToasts = false
|
showSideBetToasts = false
|
||||||
|
|
||||||
|
// Start timing for the new round (includes betting phase)
|
||||||
|
roundStartTime = Date()
|
||||||
|
Design.debugLog("🎰 New round started - roundStartTime: \(roundStartTime!)")
|
||||||
|
|
||||||
// Reset UI state
|
// Reset UI state
|
||||||
showResultBanner = false
|
showResultBanner = false
|
||||||
lastRoundResult = nil
|
lastRoundResult = nil
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
//
|
//
|
||||||
// Hand.swift
|
// BlackjackHand.swift
|
||||||
// Blackjack
|
// Blackjack
|
||||||
//
|
//
|
||||||
// Represents a Blackjack hand with value calculation.
|
// Model representing a Blackjack hand with value calculation.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@ -68,6 +68,14 @@ struct BlackjackHand: Identifiable, Equatable {
|
|||||||
|
|
||||||
/// Calculates both hard and soft values.
|
/// Calculates both hard and soft values.
|
||||||
private func calculateValues() -> (hard: Int, soft: Int) {
|
private func calculateValues() -> (hard: Int, soft: Int) {
|
||||||
|
Self.calculateValues(for: cards)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Static Helpers for Card Value Calculation
|
||||||
|
|
||||||
|
/// Calculates hard and soft values for any array of cards.
|
||||||
|
/// Use this for calculating values of partial hands (e.g., during animations).
|
||||||
|
static func calculateValues(for cards: [Card]) -> (hard: Int, soft: Int) {
|
||||||
var hardValue = 0
|
var hardValue = 0
|
||||||
var aceCount = 0
|
var aceCount = 0
|
||||||
|
|
||||||
@ -98,6 +106,18 @@ struct BlackjackHand: Identifiable, Equatable {
|
|||||||
return (hardValue, softValue)
|
return (hardValue, softValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the best value (highest without busting) for any array of cards.
|
||||||
|
static func bestValue(for cards: [Card]) -> Int {
|
||||||
|
let (hard, soft) = calculateValues(for: cards)
|
||||||
|
return soft <= 21 ? soft : hard
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the cards have a usable soft ace.
|
||||||
|
static func hasSoftAce(for cards: [Card]) -> Bool {
|
||||||
|
let (hard, soft) = calculateValues(for: cards)
|
||||||
|
return soft <= 21 && soft != hard
|
||||||
|
}
|
||||||
|
|
||||||
/// Display string for the hand value.
|
/// Display string for the hand value.
|
||||||
var valueDisplay: String {
|
var valueDisplay: String {
|
||||||
if isBlackjack {
|
if isBlackjack {
|
||||||
@ -1080,6 +1080,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Average bet" : {
|
||||||
|
"comment" : "Label for the average bet value in the Statistics Sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Baccarat" : {
|
"Baccarat" : {
|
||||||
"comment" : "The name of a casino game.",
|
"comment" : "The name of a casino game.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -1105,6 +1109,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Balance" : {
|
"Balance" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -1198,6 +1203,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Best" : {
|
"Best" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -1219,6 +1225,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Best gain" : {
|
||||||
|
"comment" : "Label in the statistics sheet for the player's best single win.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Bet 2x minimum" : {
|
"Bet 2x minimum" : {
|
||||||
"comment" : "Betting recommendation based on a true count of 1.",
|
"comment" : "Betting recommendation based on a true count of 1.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -1482,7 +1492,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Biggest bet" : {
|
||||||
|
"comment" : "Label for the \"Biggest bet\" row in the Statistics Sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"BIGGEST SWINGS" : {
|
"BIGGEST SWINGS" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -1865,6 +1880,10 @@
|
|||||||
},
|
},
|
||||||
"Change table limits, rules, and side bets in settings" : {
|
"Change table limits, rules, and side bets in settings" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"CHIPS STATS" : {
|
||||||
|
"comment" : "Title of a section in the Statistics Sheet that shows statistics related to the user's chips.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Chips, cards, and results" : {
|
"Chips, cards, and results" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -3410,6 +3429,10 @@
|
|||||||
},
|
},
|
||||||
"Get closer to 21 than the dealer without going over" : {
|
"Get closer to 21 than the dealer without going over" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"GLOBAL" : {
|
||||||
|
"comment" : "Title for the \"Global\" tab in the statistics sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"H17 rule, increases house edge" : {
|
"H17 rule, increases house edge" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -3455,6 +3478,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Hands played" : {
|
||||||
|
"comment" : "A label describing the number of hands a player has played in a game.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Haptic Feedback" : {
|
"Haptic Feedback" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -3846,6 +3873,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"IN GAME STATS" : {
|
||||||
|
"comment" : "Title of a section in the Statistics Sheet that shows in-game statistics.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Increase bets when the count is positive." : {
|
"Increase bets when the count is positive." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -4164,6 +4195,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Losses" : {
|
"Losses" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -4185,6 +4217,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Lost hands" : {
|
||||||
|
"comment" : "Label for a circle that shows the number of lost blackjack hands in the statistics sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Lower house edge" : {
|
"Lower house edge" : {
|
||||||
"comment" : "Description of a deck count option when the user selects 2 decks.",
|
"comment" : "Description of a deck count option when the user selects 2 decks.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -4438,6 +4474,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Net" : {
|
"Net" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -4826,6 +4863,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"OUTCOMES" : {
|
"OUTCOMES" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -5193,7 +5231,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Pushed hands" : {
|
||||||
|
"comment" : "Label for the \"Pushed hands\" outcome circle in the statistics sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Pushes" : {
|
"Pushes" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -5399,6 +5442,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Rounds" : {
|
"Rounds" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -5558,6 +5602,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"SESSION SUMMARY" : {
|
"SESSION SUMMARY" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -6724,6 +6769,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Total bet" : {
|
||||||
|
"comment" : "Label for the total bet value in the Statistics Sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Total gain" : {
|
||||||
|
"comment" : "Label in the Statistics sheet for the total gain (profit or loss) from playing blackjack.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Total game time" : {
|
||||||
|
"comment" : "Label for a stat row displaying the total game time.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Total Winnings" : {
|
"Total Winnings" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -6927,6 +6984,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Win Rate" : {
|
"Win Rate" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -6995,6 +7053,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Wins" : {
|
"Wins" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -7016,7 +7075,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Won hands" : {
|
||||||
|
"comment" : "Label for a circle that represents the number of hands the user has won in a statistics sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Worst" : {
|
"Worst" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -7038,6 +7102,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Worst loss" : {
|
||||||
|
"comment" : "Description of a chip stat row when displaying the worst loss.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Yes ($%lld)" : {
|
"Yes ($%lld)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
@ -11,9 +11,28 @@ import CasinoKit
|
|||||||
/// Saved round result for history.
|
/// Saved round result for history.
|
||||||
struct SavedRoundResult: Codable, Equatable {
|
struct SavedRoundResult: Codable, Equatable {
|
||||||
let date: Date
|
let date: Date
|
||||||
|
let gameStyle: String // "vegas", "atlantic", "european", "custom"
|
||||||
let mainResult: String // "blackjack", "win", "lose", "push", "bust", "surrender"
|
let mainResult: String // "blackjack", "win", "lose", "push", "bust", "surrender"
|
||||||
let hadSplit: Bool
|
let hadSplit: Bool
|
||||||
let totalWinnings: Int
|
let totalWinnings: Int
|
||||||
|
let roundDuration: TimeInterval // Duration in seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-style statistics for tracking.
|
||||||
|
struct StyleStatistics: Codable, Equatable {
|
||||||
|
var roundsPlayed: Int = 0
|
||||||
|
var wins: Int = 0
|
||||||
|
var losses: Int = 0
|
||||||
|
var pushes: Int = 0
|
||||||
|
var blackjacks: Int = 0
|
||||||
|
var busts: Int = 0
|
||||||
|
var surrenders: Int = 0
|
||||||
|
var totalWinnings: Int = 0
|
||||||
|
var biggestWin: Int = 0
|
||||||
|
var biggestLoss: Int = 0
|
||||||
|
var totalPlayTime: TimeInterval = 0 // Cumulative seconds
|
||||||
|
var totalBetAmount: Int = 0
|
||||||
|
var biggestBet: Int = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persistent game data that syncs to iCloud.
|
/// Persistent game data that syncs to iCloud.
|
||||||
@ -28,21 +47,29 @@ struct BlackjackGameData: PersistableGameData {
|
|||||||
lastModified: Date(),
|
lastModified: Date(),
|
||||||
balance: 10_000,
|
balance: 10_000,
|
||||||
roundHistory: [],
|
roundHistory: [],
|
||||||
|
styleStats: [:],
|
||||||
totalWinnings: 0,
|
totalWinnings: 0,
|
||||||
biggestWin: 0,
|
biggestWin: 0,
|
||||||
biggestLoss: 0,
|
biggestLoss: 0,
|
||||||
blackjackCount: 0,
|
blackjackCount: 0,
|
||||||
bustCount: 0
|
bustCount: 0,
|
||||||
|
totalPlayTime: 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var balance: Int
|
var balance: Int
|
||||||
var roundHistory: [SavedRoundResult]
|
var roundHistory: [SavedRoundResult]
|
||||||
|
|
||||||
|
/// Per-style statistics keyed by style rawValue.
|
||||||
|
var styleStats: [String: StyleStatistics]
|
||||||
|
|
||||||
|
// Legacy global stats (kept for backward compatibility)
|
||||||
var totalWinnings: Int
|
var totalWinnings: Int
|
||||||
var biggestWin: Int
|
var biggestWin: Int
|
||||||
var biggestLoss: Int
|
var biggestLoss: Int
|
||||||
var blackjackCount: Int
|
var blackjackCount: Int
|
||||||
var bustCount: Int
|
var bustCount: Int
|
||||||
|
var totalPlayTime: TimeInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persistent settings data that syncs to iCloud.
|
/// Persistent settings data that syncs to iCloud.
|
||||||
|
|||||||
@ -20,7 +20,7 @@ enum Design {
|
|||||||
static let showDebugBorders = false
|
static let showDebugBorders = false
|
||||||
|
|
||||||
/// Set to true to show debug log statements
|
/// Set to true to show debug log statements
|
||||||
static let showDebugLogs = false
|
static let showDebugLogs = true
|
||||||
|
|
||||||
/// Debug logger - only prints when showDebugLogs is true
|
/// Debug logger - only prints when showDebugLogs is true
|
||||||
static func debugLog(_ message: String) {
|
static func debugLog(_ message: String) {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
// StatisticsSheetView.swift
|
// StatisticsSheetView.swift
|
||||||
// Blackjack
|
// Blackjack
|
||||||
//
|
//
|
||||||
// Game statistics and history.
|
// Game statistics with Global and per-style tabs.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@ -12,217 +12,478 @@ struct StatisticsSheetView: View {
|
|||||||
let state: GameState
|
let state: GameState
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var selectedPage: Int = 0
|
||||||
|
|
||||||
// MARK: - Computed Stats
|
/// All available statistics pages (Global + each style).
|
||||||
|
private var pages: [StatisticsPage] {
|
||||||
private var totalRounds: Int {
|
var result: [StatisticsPage] = [
|
||||||
state.roundHistory.count
|
.global(computeGlobalStats())
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add pages for each style that has been played
|
||||||
|
for style in BlackjackStyle.allCases where style != .custom {
|
||||||
|
if let stats = state.styleStats[style.rawValue], stats.roundsPlayed > 0 {
|
||||||
|
result.append(.style(style, stats))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom if it has been played
|
||||||
|
if let customStats = state.styleStats[BlackjackStyle.custom.rawValue], customStats.roundsPlayed > 0 {
|
||||||
|
result.append(.style(.custom, customStats))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private var wins: Int {
|
/// Computes aggregated global statistics from all styles.
|
||||||
state.roundHistory.filter { $0.mainHandResult.isWin }.count
|
private func computeGlobalStats() -> StyleStatistics {
|
||||||
}
|
var global = StyleStatistics()
|
||||||
|
|
||||||
private var losses: Int {
|
for (_, stats) in state.styleStats {
|
||||||
state.roundHistory.filter {
|
global.roundsPlayed += stats.roundsPlayed
|
||||||
$0.mainHandResult == .lose || $0.mainHandResult == .bust
|
global.wins += stats.wins
|
||||||
}.count
|
global.losses += stats.losses
|
||||||
}
|
global.pushes += stats.pushes
|
||||||
|
global.blackjacks += stats.blackjacks
|
||||||
private var pushes: Int {
|
global.busts += stats.busts
|
||||||
state.roundHistory.filter { $0.mainHandResult == .push }.count
|
global.surrenders += stats.surrenders
|
||||||
}
|
global.totalWinnings += stats.totalWinnings
|
||||||
|
global.totalPlayTime += stats.totalPlayTime
|
||||||
private var blackjacks: Int {
|
global.totalBetAmount += stats.totalBetAmount
|
||||||
state.roundHistory.filter { $0.mainHandResult == .blackjack }.count
|
|
||||||
}
|
if stats.biggestWin > global.biggestWin {
|
||||||
|
global.biggestWin = stats.biggestWin
|
||||||
private var busts: Int {
|
}
|
||||||
state.roundHistory.filter { $0.mainHandResult == .bust }.count
|
if stats.biggestLoss < global.biggestLoss {
|
||||||
}
|
global.biggestLoss = stats.biggestLoss
|
||||||
|
}
|
||||||
private var surrenders: Int {
|
if stats.biggestBet > global.biggestBet {
|
||||||
state.roundHistory.filter { $0.mainHandResult == .surrender }.count
|
global.biggestBet = stats.biggestBet
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private var winRate: Double {
|
|
||||||
guard totalRounds > 0 else { return 0 }
|
// If no style stats exist yet, use session data
|
||||||
return Double(wins) / Double(totalRounds) * 100
|
if global.roundsPlayed == 0 {
|
||||||
}
|
global.roundsPlayed = state.roundHistory.count
|
||||||
|
global.wins = state.roundHistory.filter { $0.mainHandResult.isWin }.count
|
||||||
private var totalWinnings: Int {
|
global.losses = state.roundHistory.filter { $0.mainHandResult == .lose || $0.mainHandResult == .bust }.count
|
||||||
state.roundHistory.reduce(0) { $0 + $1.totalWinnings }
|
global.pushes = state.roundHistory.filter { $0.mainHandResult == .push }.count
|
||||||
}
|
global.blackjacks = state.roundHistory.filter { $0.mainHandResult == .blackjack }.count
|
||||||
|
global.busts = state.roundHistory.filter { $0.mainHandResult == .bust }.count
|
||||||
private var biggestWin: Int {
|
global.surrenders = state.roundHistory.filter { $0.mainHandResult == .surrender }.count
|
||||||
state.roundHistory.map { $0.totalWinnings }.filter { $0 > 0 }.max() ?? 0
|
global.totalWinnings = state.roundHistory.reduce(0) { $0 + $1.totalWinnings }
|
||||||
}
|
global.totalPlayTime = state.totalPlayTime
|
||||||
|
}
|
||||||
private var biggestLoss: Int {
|
|
||||||
state.roundHistory.map { $0.totalWinnings }.filter { $0 < 0 }.min() ?? 0
|
return global
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SheetContainerView(
|
SheetContainerView(
|
||||||
title: String(localized: "Statistics"),
|
title: String(localized: "Statistics"),
|
||||||
content: {
|
content: {
|
||||||
// Session Summary
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
SheetSection(title: String(localized: "SESSION SUMMARY"), icon: "chart.bar.fill") {
|
// Page selector with current style header
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: Design.Spacing.medium) {
|
pageHeader
|
||||||
StatBox(title: String(localized: "Rounds"), value: "\(totalRounds)", color: .white)
|
|
||||||
StatBox(title: String(localized: "Win Rate"), value: formatPercent(winRate), color: winRate >= 50 ? .green : .orange)
|
// Page indicator dots
|
||||||
StatBox(title: String(localized: "Net"), value: formatMoney(totalWinnings), color: totalWinnings >= 0 ? .green : .red)
|
pageIndicator
|
||||||
StatBox(title: String(localized: "Balance"), value: "$\(state.balance)", color: Color.Sheet.accent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Win Distribution
|
// Current page content
|
||||||
SheetSection(title: String(localized: "OUTCOMES"), icon: "chart.pie.fill") {
|
if selectedPage < pages.count {
|
||||||
VStack(spacing: Design.Spacing.small) {
|
statisticsContent(for: pages[selectedPage])
|
||||||
OutcomeRow(label: String(localized: "Blackjacks"), count: blackjacks, total: totalRounds, color: .yellow)
|
|
||||||
OutcomeRow(label: String(localized: "Wins"), count: wins - blackjacks, total: totalRounds, color: .green)
|
|
||||||
OutcomeRow(label: String(localized: "Pushes"), count: pushes, total: totalRounds, color: .blue)
|
|
||||||
OutcomeRow(label: String(localized: "Losses"), count: losses - busts, total: totalRounds, color: .orange)
|
|
||||||
OutcomeRow(label: String(localized: "Busts"), count: busts, total: totalRounds, color: .red)
|
|
||||||
if surrenders > 0 {
|
|
||||||
OutcomeRow(label: String(localized: "Surrenders"), count: surrenders, total: totalRounds, color: .gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Biggest Swings
|
|
||||||
if totalRounds > 0 {
|
|
||||||
SheetSection(title: String(localized: "BIGGEST SWINGS"), icon: "arrow.up.arrow.down") {
|
|
||||||
HStack(spacing: Design.Spacing.large) {
|
|
||||||
VStack(spacing: Design.Spacing.xSmall) {
|
|
||||||
Text(String(localized: "Best"))
|
|
||||||
.font(.system(size: Design.BaseFontSize.small))
|
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
||||||
Text(formatMoney(biggestWin))
|
|
||||||
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
.frame(height: 40)
|
|
||||||
.background(Color.white.opacity(Design.Opacity.hint))
|
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.xSmall) {
|
|
||||||
Text(String(localized: "Worst"))
|
|
||||||
.font(.system(size: Design.BaseFontSize.small))
|
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
||||||
Text(formatMoney(biggestLoss))
|
|
||||||
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCancel: nil,
|
onCancel: nil,
|
||||||
onDone: { dismiss() },
|
onDone: { dismiss() },
|
||||||
doneButtonText: String(localized: "Done")
|
doneButtonText: String(localized: "Done")
|
||||||
)
|
)
|
||||||
|
.gesture(
|
||||||
|
DragGesture()
|
||||||
|
.onEnded { value in
|
||||||
|
let threshold: CGFloat = 50
|
||||||
|
if value.translation.width < -threshold && selectedPage < pages.count - 1 {
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||||
|
selectedPage += 1
|
||||||
|
}
|
||||||
|
} else if value.translation.width > threshold && selectedPage > 0 {
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||||
|
selectedPage -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Page Header
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var pageHeader: some View {
|
||||||
|
let currentPage = selectedPage < pages.count ? pages[selectedPage] : .global(StyleStatistics())
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Left arrow
|
||||||
|
Button {
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||||
|
if selectedPage > 0 {
|
||||||
|
selectedPage -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
|
||||||
|
.foregroundStyle(selectedPage > 0 ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.hint))
|
||||||
|
}
|
||||||
|
.disabled(selectedPage == 0)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Page title with icon
|
||||||
|
VStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Image(systemName: currentPage.icon)
|
||||||
|
.font(.system(size: Design.BaseFontSize.xxLarge))
|
||||||
|
.foregroundStyle(currentPage.accentColor)
|
||||||
|
|
||||||
|
Text(currentPage.title)
|
||||||
|
.font(.system(size: Design.BaseFontSize.title, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Right arrow
|
||||||
|
Button {
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||||
|
if selectedPage < pages.count - 1 {
|
||||||
|
selectedPage += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
|
||||||
|
.foregroundStyle(selectedPage < pages.count - 1 ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.hint))
|
||||||
|
}
|
||||||
|
.disabled(selectedPage >= pages.count - 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
.padding(.top, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page Indicator
|
||||||
|
|
||||||
|
private var pageIndicator: some View {
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
ForEach(pages.indices, id: \.self) { index in
|
||||||
|
Circle()
|
||||||
|
.fill(index == selectedPage ? Color.Sheet.accent : Color.white.opacity(Design.Opacity.light))
|
||||||
|
.frame(width: Design.Spacing.small, height: Design.Spacing.small)
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||||
|
selectedPage = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Statistics Content
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func statisticsContent(for page: StatisticsPage) -> some View {
|
||||||
|
let stats = page.statistics
|
||||||
|
|
||||||
|
// In-Game Stats section
|
||||||
|
SheetSection(title: String(localized: "IN GAME STATS"), icon: "gamecontroller.fill") {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
// Hands played
|
||||||
|
VStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(String(localized: "Hands played"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.body))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
|
Text("\(stats.roundsPlayed)")
|
||||||
|
.font(.system(size: Design.BaseFontSize.xxLarge, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Win/Loss/Push distribution
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
OutcomeCircle(
|
||||||
|
label: String(localized: "Won hands"),
|
||||||
|
count: stats.wins,
|
||||||
|
color: .white
|
||||||
|
)
|
||||||
|
OutcomeCircle(
|
||||||
|
label: String(localized: "Lost hands"),
|
||||||
|
count: stats.losses,
|
||||||
|
color: Color.red
|
||||||
|
)
|
||||||
|
OutcomeCircle(
|
||||||
|
label: String(localized: "Pushed hands"),
|
||||||
|
count: stats.pushes,
|
||||||
|
color: .gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(Design.Opacity.hint))
|
||||||
|
|
||||||
|
// Game time and special outcomes
|
||||||
|
StatRow(icon: "clock", label: String(localized: "Total game time"), value: formatTime(stats.totalPlayTime))
|
||||||
|
StatRow(icon: "21.circle.fill", label: String(localized: "Blackjacks"), value: "\(stats.blackjacks)", valueColor: .yellow)
|
||||||
|
StatRow(icon: "flame.fill", label: String(localized: "Busts"), value: "\(stats.busts)", valueColor: .orange)
|
||||||
|
|
||||||
|
if stats.surrenders > 0 {
|
||||||
|
StatRow(icon: "flag.fill", label: String(localized: "Surrenders"), value: "\(stats.surrenders)", valueColor: .gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chips Stats section
|
||||||
|
SheetSection(title: String(localized: "CHIPS STATS"), icon: "dollarsign.circle.fill") {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
ChipStatRow(
|
||||||
|
icon: "chart.line.uptrend.xyaxis",
|
||||||
|
iconColor: stats.totalWinnings >= 0 ? .green : .red,
|
||||||
|
label: String(localized: "Total gain"),
|
||||||
|
value: formatMoney(stats.totalWinnings)
|
||||||
|
)
|
||||||
|
|
||||||
|
ChipStatRow(
|
||||||
|
icon: "arrow.up.circle.fill",
|
||||||
|
iconColor: .green,
|
||||||
|
label: String(localized: "Best gain"),
|
||||||
|
value: formatMoney(stats.biggestWin)
|
||||||
|
)
|
||||||
|
|
||||||
|
ChipStatRow(
|
||||||
|
icon: "arrow.down.circle.fill",
|
||||||
|
iconColor: .red,
|
||||||
|
label: String(localized: "Worst loss"),
|
||||||
|
value: formatMoney(stats.biggestLoss)
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color.white.opacity(Design.Opacity.hint))
|
||||||
|
|
||||||
|
ChipStatRow(
|
||||||
|
icon: "plusminus.circle.fill",
|
||||||
|
iconColor: .blue,
|
||||||
|
label: String(localized: "Total bet"),
|
||||||
|
value: "$\(stats.totalBetAmount)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if stats.roundsPlayed > 0 {
|
||||||
|
ChipStatRow(
|
||||||
|
icon: "equal.circle.fill",
|
||||||
|
iconColor: .purple,
|
||||||
|
label: String(localized: "Average bet"),
|
||||||
|
value: "$\(stats.totalBetAmount / stats.roundsPlayed)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ChipStatRow(
|
||||||
|
icon: "star.circle.fill",
|
||||||
|
iconColor: .orange,
|
||||||
|
label: String(localized: "Biggest bet"),
|
||||||
|
value: "$\(stats.biggestBet)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Formatters
|
||||||
|
|
||||||
private func formatMoney(_ amount: Int) -> String {
|
private func formatMoney(_ amount: Int) -> String {
|
||||||
if amount >= 0 {
|
if amount >= 0 {
|
||||||
return "+$\(amount)"
|
return "$\(amount)"
|
||||||
} else {
|
} else {
|
||||||
return "-$\(abs(amount))"
|
return "-$\(abs(amount))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatPercent(_ value: Double) -> String {
|
private func formatTime(_ seconds: TimeInterval) -> String {
|
||||||
value.formatted(.number.precision(.fractionLength(1))) + "%"
|
let hours = Int(seconds) / 3600
|
||||||
|
let minutes = (Int(seconds) % 3600) / 60
|
||||||
|
return String(format: "%02dh %02dmin", hours, minutes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Stat Box
|
// MARK: - Statistics Page Type
|
||||||
|
|
||||||
struct StatBox: View {
|
private enum StatisticsPage: Identifiable {
|
||||||
let title: String
|
case global(StyleStatistics)
|
||||||
let value: String
|
case style(BlackjackStyle, StyleStatistics)
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .global:
|
||||||
|
return "global"
|
||||||
|
case .style(let style, _):
|
||||||
|
return style.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .global:
|
||||||
|
return String(localized: "GLOBAL")
|
||||||
|
case .style(let style, _):
|
||||||
|
return style.displayName.uppercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .global:
|
||||||
|
return "globe"
|
||||||
|
case .style(let style, _):
|
||||||
|
switch style {
|
||||||
|
case .vegas:
|
||||||
|
return "building.2.fill"
|
||||||
|
case .atlantic:
|
||||||
|
return "water.waves"
|
||||||
|
case .european:
|
||||||
|
return "flag.fill"
|
||||||
|
case .custom:
|
||||||
|
return "slider.horizontal.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var accentColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .global:
|
||||||
|
return Color.Sheet.accent
|
||||||
|
case .style(let style, _):
|
||||||
|
switch style {
|
||||||
|
case .vegas:
|
||||||
|
return .orange
|
||||||
|
case .atlantic:
|
||||||
|
return .cyan
|
||||||
|
case .european:
|
||||||
|
return .blue
|
||||||
|
case .custom:
|
||||||
|
return .purple
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var statistics: StyleStatistics {
|
||||||
|
switch self {
|
||||||
|
case .global(let stats):
|
||||||
|
return stats
|
||||||
|
case .style(_, let stats):
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Views
|
||||||
|
|
||||||
|
private struct OutcomeCircle: View {
|
||||||
|
let label: String
|
||||||
|
let count: Int
|
||||||
let color: Color
|
let color: Color
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: Design.Spacing.xSmall) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
Text(title)
|
Text(label)
|
||||||
.font(.system(size: Design.BaseFontSize.small))
|
.font(.system(size: Design.BaseFontSize.small))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
Text(value)
|
Text("\(count)")
|
||||||
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded))
|
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(.white)
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.7)
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.black.opacity(Design.Opacity.light))
|
||||||
|
.frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.stroke(color, lineWidth: Design.LineWidth.thick)
|
||||||
|
.frame(width: Size.outcomeCircleSize, height: Size.outcomeCircleSize)
|
||||||
|
|
||||||
|
// Inner filled circle
|
||||||
|
Circle()
|
||||||
|
.fill(color.opacity(Design.Opacity.medium))
|
||||||
|
.frame(width: Size.outcomeCircleInner, height: Size.outcomeCircleInner)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(Design.Spacing.medium)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
|
|
||||||
.fill(Color.white.opacity(Design.Opacity.subtle))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Outcome Row
|
private struct StatRow: View {
|
||||||
|
let icon: String
|
||||||
struct OutcomeRow: View {
|
|
||||||
let label: String
|
let label: String
|
||||||
let count: Int
|
let value: String
|
||||||
let total: Int
|
var valueColor: Color = .white
|
||||||
let color: Color
|
|
||||||
|
|
||||||
private var percentage: Double {
|
|
||||||
guard total > 0 else { return 0 }
|
|
||||||
return Double(count) / Double(total) * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatPercentWhole(_ value: Double) -> String {
|
|
||||||
value.formatted(.number.precision(.fractionLength(0))) + "%"
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
// Label
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: Design.BaseFontSize.large))
|
||||||
|
.foregroundStyle(Color.Sheet.accent)
|
||||||
|
.frame(width: Size.statIconWidth)
|
||||||
|
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.system(size: Design.BaseFontSize.body))
|
.font(.system(size: Design.BaseFontSize.body))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Count
|
Text(value)
|
||||||
Text("\(count)")
|
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
|
||||||
.font(.system(size: Design.BaseFontSize.body, weight: .bold))
|
.foregroundStyle(valueColor)
|
||||||
.foregroundStyle(color)
|
|
||||||
|
|
||||||
// Progress bar
|
|
||||||
GeometryReader { geometry in
|
|
||||||
ZStack(alignment: .leading) {
|
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
|
|
||||||
.fill(Color.white.opacity(Design.Opacity.subtle))
|
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
|
|
||||||
.fill(color)
|
|
||||||
.frame(width: geometry.size.width * CGFloat(percentage / 100))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 60, height: 8)
|
|
||||||
|
|
||||||
// Percentage
|
|
||||||
Text(formatPercentWhole(percentage))
|
|
||||||
.font(.system(size: Design.BaseFontSize.small, design: .rounded))
|
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
||||||
.frame(width: 40, alignment: .trailing)
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ChipStatRow: View {
|
||||||
|
let icon: String
|
||||||
|
let iconColor: Color
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
// Chip-style icon
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(iconColor)
|
||||||
|
.frame(width: Size.chipIconSize, height: Size.chipIconSize)
|
||||||
|
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: Design.BaseFontSize.small, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: Design.BaseFontSize.body))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundStyle(Color.Sheet.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Local Size Constants
|
||||||
|
|
||||||
|
private enum Size {
|
||||||
|
static let outcomeCircleSize: CGFloat = 48
|
||||||
|
static let outcomeCircleInner: CGFloat = 24
|
||||||
|
static let statIconWidth: CGFloat = 32
|
||||||
|
static let chipIconSize: CGFloat = 28
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
StatisticsSheetView(state: GameState(settings: GameSettings()))
|
StatisticsSheetView(state: GameState(settings: GameSettings()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -119,7 +119,7 @@ struct BlackjackTableView: View {
|
|||||||
// Player hands area - only show when there are cards dealt
|
// Player hands area - only show when there are cards dealt
|
||||||
if state.playerHands.first?.cards.isEmpty == false {
|
if state.playerHands.first?.cards.isEmpty == false {
|
||||||
ZStack {
|
ZStack {
|
||||||
PlayerHandsView(
|
PlayerHandsContainer(
|
||||||
hands: state.playerHands,
|
hands: state.playerHands,
|
||||||
activeHandIndex: state.activeHandIndex,
|
activeHandIndex: state.activeHandIndex,
|
||||||
isPlayerTurn: state.isPlayerTurn,
|
isPlayerTurn: state.isPlayerTurn,
|
||||||
|
|||||||
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 labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
||||||
@ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge
|
@ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge
|
||||||
|
|
||||||
/// Scaled animation duration based on dealing speed.
|
|
||||||
private var animationDuration: Double {
|
|
||||||
Design.Animation.springDuration * dealingSpeed
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
@ -39,17 +34,14 @@ struct DealerHandView: View {
|
|||||||
|
|
||||||
// Calculate value from visible cards only
|
// Calculate value from visible cards only
|
||||||
if !hand.cards.isEmpty && visibleCardCount > 0 {
|
if !hand.cards.isEmpty && visibleCardCount > 0 {
|
||||||
if showHoleCard && visibleCardCount >= hand.cards.count {
|
if showHoleCard {
|
||||||
// All cards visible - calculate total hand value from visible cards
|
// Hole card revealed - calculate value from visible cards
|
||||||
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
|
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
|
||||||
let visibleValue = visibleCards.reduce(0) { $0 + $1.blackjackValue }
|
let displayValue = BlackjackHand.bestValue(for: visibleCards)
|
||||||
let visibleHasSoftAce = visibleCards.contains { $0.rank == .ace } && visibleValue + 10 <= 21
|
|
||||||
let displayValue = visibleHasSoftAce && visibleValue + 10 <= 21 ? visibleValue + 10 : visibleValue
|
|
||||||
|
|
||||||
ValueBadge(value: displayValue, color: Color.Hand.dealer)
|
ValueBadge(value: displayValue, color: Color.Hand.dealer)
|
||||||
.animation(nil, value: displayValue) // No animation when value changes
|
.animation(nil, value: displayValue) // No animation when value changes
|
||||||
} else if visibleCardCount >= 1 {
|
} else {
|
||||||
// Hole card hidden or not all cards visible - show only the first (face-up) card's value
|
// Hole card hidden - show only the first (face-up) card's value
|
||||||
ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer)
|
ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer)
|
||||||
.animation(nil, value: hand.cards[0].blackjackValue) // No animation when value changes
|
.animation(nil, value: hand.cards[0].blackjackValue) // No animation when value changes
|
||||||
}
|
}
|
||||||
@ -61,48 +53,22 @@ struct DealerHandView: View {
|
|||||||
.animation(nil, value: showHoleCard)
|
.animation(nil, value: showHoleCard)
|
||||||
// Cards with result badge overlay (overlay prevents height change)
|
// Cards with result badge overlay (overlay prevents height change)
|
||||||
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
|
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
|
||||||
if hand.cards.isEmpty {
|
CardStackView.dealer(
|
||||||
|
cards: hand.cards,
|
||||||
|
showHoleCard: showHoleCard,
|
||||||
|
cardWidth: cardWidth,
|
||||||
|
cardSpacing: cardSpacing,
|
||||||
|
showAnimations: showAnimations,
|
||||||
|
dealingSpeed: dealingSpeed,
|
||||||
|
showCardCount: showCardCount
|
||||||
|
)
|
||||||
|
|
||||||
|
// Show placeholder for second card in European mode (no hole card)
|
||||||
|
if hand.cards.count == 1 && !showHoleCard {
|
||||||
CardPlaceholderView(width: cardWidth)
|
CardPlaceholderView(width: cardWidth)
|
||||||
CardPlaceholderView(width: cardWidth)
|
.opacity(Design.Opacity.medium)
|
||||||
} else {
|
|
||||||
ForEach(hand.cards.indices, id: \.self) { index in
|
|
||||||
let isFaceUp = index == 0 || showHoleCard
|
|
||||||
CardView(
|
|
||||||
card: hand.cards[index],
|
|
||||||
isFaceUp: isFaceUp,
|
|
||||||
cardWidth: cardWidth
|
|
||||||
)
|
|
||||||
.overlay(alignment: .bottomLeading) {
|
|
||||||
if showCardCount && isFaceUp {
|
|
||||||
HiLoCountBadge(card: hand.cards[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.zIndex(Double(index))
|
|
||||||
.transition(
|
|
||||||
showAnimations
|
|
||||||
? .asymmetric(
|
|
||||||
insertion: .offset(x: Design.DealAnimation.dealerOffsetX, y: Design.DealAnimation.dealerOffsetY)
|
|
||||||
.combined(with: .opacity)
|
|
||||||
.combined(with: .scale(scale: Design.Scale.slightShrink)),
|
|
||||||
removal: .scale.combined(with: .opacity)
|
|
||||||
)
|
|
||||||
: .identity
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show placeholder for second card in European mode (no hole card)
|
|
||||||
if hand.cards.count == 1 && !showHoleCard {
|
|
||||||
CardPlaceholderView(width: cardWidth)
|
|
||||||
.opacity(Design.Opacity.medium)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(
|
|
||||||
showAnimations
|
|
||||||
? .spring(duration: animationDuration, bounce: Design.Animation.springBounce)
|
|
||||||
: .none,
|
|
||||||
value: hand.cards.count
|
|
||||||
)
|
|
||||||
.overlay {
|
.overlay {
|
||||||
// Result badge - centered on cards
|
// Result badge - centered on cards
|
||||||
if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil {
|
if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil {
|
||||||
|
|||||||
@ -2,103 +2,12 @@
|
|||||||
// PlayerHandView.swift
|
// PlayerHandView.swift
|
||||||
// Blackjack
|
// Blackjack
|
||||||
//
|
//
|
||||||
// Displays player hands in a horizontally scrollable container.
|
// Displays a single player hand with cards, value, bet, and result.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CasinoKit
|
import CasinoKit
|
||||||
|
|
||||||
// MARK: - Player Hands Container
|
|
||||||
|
|
||||||
/// Container for multiple player hands with horizontal scrolling.
|
|
||||||
struct PlayerHandsView: View {
|
|
||||||
let hands: [BlackjackHand]
|
|
||||||
let activeHandIndex: Int
|
|
||||||
let isPlayerTurn: Bool
|
|
||||||
let showCardCount: Bool
|
|
||||||
let showAnimations: Bool
|
|
||||||
let dealingSpeed: Double
|
|
||||||
let cardWidth: CGFloat
|
|
||||||
let cardSpacing: CGFloat
|
|
||||||
|
|
||||||
/// Number of visible cards for each hand (completed animations)
|
|
||||||
let visibleCardCounts: [Int]
|
|
||||||
|
|
||||||
/// Current hint to display (shown on active hand only).
|
|
||||||
let currentHint: String?
|
|
||||||
|
|
||||||
/// Whether the hint toast should be visible.
|
|
||||||
let showHintToast: Bool
|
|
||||||
|
|
||||||
/// Total card count across all hands - used to trigger scroll when hitting
|
|
||||||
private var totalCardCount: Int {
|
|
||||||
hands.reduce(0) { $0 + $1.cards.count }
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollViewReader { proxy in
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: Design.Spacing.large) {
|
|
||||||
// Display hands in reverse order (right to left play order)
|
|
||||||
// Visual order: Hand 3, Hand 2, Hand 1 (left to right)
|
|
||||||
// Play order: Hand 1 played first (rightmost), then Hand 2, etc.
|
|
||||||
ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in
|
|
||||||
let isActiveHand = index == activeHandIndex && isPlayerTurn
|
|
||||||
let visibleCount = index < visibleCardCounts.count ? visibleCardCounts[index] : 0
|
|
||||||
PlayerHandView(
|
|
||||||
hand: hand,
|
|
||||||
isActive: isActiveHand,
|
|
||||||
showCardCount: showCardCount,
|
|
||||||
showAnimations: showAnimations,
|
|
||||||
dealingSpeed: dealingSpeed,
|
|
||||||
// Hand numbers: rightmost (index 0) is Hand 1, played first
|
|
||||||
handNumber: hands.count > 1 ? index + 1 : nil,
|
|
||||||
cardWidth: cardWidth,
|
|
||||||
cardSpacing: cardSpacing,
|
|
||||||
visibleCardCount: visibleCount,
|
|
||||||
// Only show hint on the active hand
|
|
||||||
currentHint: isActiveHand ? currentHint : nil,
|
|
||||||
showHintToast: isActiveHand && showHintToast
|
|
||||||
)
|
|
||||||
.id(hand.id)
|
|
||||||
.transition(.scale.combined(with: .opacity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.spring(duration: Design.Animation.springDuration), value: hands.count)
|
|
||||||
.padding(.horizontal, Design.Spacing.xxLarge) // More padding for scrolling
|
|
||||||
}
|
|
||||||
.scrollClipDisabled()
|
|
||||||
.scrollBounceBehavior(.always) // Always allow bouncing for better scroll feel
|
|
||||||
.defaultScrollAnchor(.center) // Center the content by default
|
|
||||||
.onChange(of: activeHandIndex) { _, newIndex in
|
|
||||||
scrollToActiveHand(proxy: proxy)
|
|
||||||
}
|
|
||||||
.onChange(of: totalCardCount) { _, _ in
|
|
||||||
// Scroll to active hand when cards are added (hit)
|
|
||||||
scrollToActiveHand(proxy: proxy)
|
|
||||||
}
|
|
||||||
.onChange(of: hands.count) { _, _ in
|
|
||||||
// Scroll to active hand when split occurs
|
|
||||||
scrollToActiveHand(proxy: proxy)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
scrollToActiveHand(proxy: proxy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scrollToActiveHand(proxy: ScrollViewProxy) {
|
|
||||||
guard activeHandIndex < hands.count else { return }
|
|
||||||
let activeHandId = hands[activeHandIndex].id
|
|
||||||
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
|
|
||||||
proxy.scrollTo(activeHandId, anchor: .center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Single Player Hand
|
|
||||||
|
|
||||||
/// Displays a single player hand with cards, value, and result.
|
/// Displays a single player hand with cards, value, and result.
|
||||||
struct PlayerHandView: View {
|
struct PlayerHandView: View {
|
||||||
let hand: BlackjackHand
|
let hand: BlackjackHand
|
||||||
@ -119,55 +28,52 @@ struct PlayerHandView: View {
|
|||||||
/// Whether the hint toast should be visible.
|
/// Whether the hint toast should be visible.
|
||||||
let showHintToast: Bool
|
let showHintToast: Bool
|
||||||
|
|
||||||
/// Scaled animation duration based on dealing speed.
|
|
||||||
private var animationDuration: Double {
|
|
||||||
Design.Animation.springDuration * dealingSpeed
|
|
||||||
}
|
|
||||||
|
|
||||||
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
|
||||||
@ScaledMetric(relativeTo: .caption) private var handNumberSize: CGFloat = Design.Size.handNumberFontSize
|
@ScaledMetric(relativeTo: .caption) private var handNumberSize: CGFloat = Design.Size.handNumberFontSize
|
||||||
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.Size.handIconSize
|
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.Size.handIconSize
|
||||||
@ScaledMetric(relativeTo: .body) private var hintPaddingH: CGFloat = Design.Size.hintPaddingH
|
@ScaledMetric(relativeTo: .body) private var hintPaddingH: CGFloat = Design.Size.hintPaddingH
|
||||||
@ScaledMetric(relativeTo: .body) private var hintPaddingV: CGFloat = Design.Size.hintPaddingV
|
@ScaledMetric(relativeTo: .body) private var hintPaddingV: CGFloat = Design.Size.hintPaddingV
|
||||||
|
|
||||||
|
/// Calculates display info for visible cards using shared BlackjackHand logic.
|
||||||
|
private var visibleCardsDisplayInfo: (text: String, color: Color)? {
|
||||||
|
guard !hand.cards.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
|
||||||
|
guard !visibleCards.isEmpty else { return nil }
|
||||||
|
|
||||||
|
// Use shared static methods for value calculation
|
||||||
|
let (hardValue, softValue) = BlackjackHand.calculateValues(for: visibleCards)
|
||||||
|
let displayValue = BlackjackHand.bestValue(for: visibleCards)
|
||||||
|
let hasSoftAce = BlackjackHand.hasSoftAce(for: visibleCards)
|
||||||
|
|
||||||
|
// Determine color based on visible cards
|
||||||
|
let isVisibleBlackjack = visibleCards.count == 2 && displayValue == 21 && !hand.isSplit
|
||||||
|
let isVisibleBusted = hardValue > 21
|
||||||
|
let isVisible21 = displayValue == 21 && !isVisibleBlackjack
|
||||||
|
|
||||||
|
let displayColor: Color = {
|
||||||
|
if isVisibleBlackjack { return .yellow }
|
||||||
|
if isVisibleBusted { return .red }
|
||||||
|
if isVisible21 { return .green }
|
||||||
|
return .white
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Show value like hand.valueDisplay does (e.g., "8/18" for soft hands)
|
||||||
|
let valueText = hasSoftAce ? "\(hardValue)/\(softValue)" : "\(displayValue)"
|
||||||
|
|
||||||
|
return (valueText, displayColor)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
// Cards with container
|
// Cards with container
|
||||||
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
|
CardStackView.player(
|
||||||
if hand.cards.isEmpty {
|
cards: hand.cards,
|
||||||
CardPlaceholderView(width: cardWidth)
|
cardWidth: cardWidth,
|
||||||
CardPlaceholderView(width: cardWidth)
|
cardSpacing: cardSpacing,
|
||||||
} else {
|
showAnimations: showAnimations,
|
||||||
ForEach(hand.cards.indices, id: \.self) { index in
|
dealingSpeed: dealingSpeed,
|
||||||
CardView(
|
showCardCount: showCardCount
|
||||||
card: hand.cards[index],
|
|
||||||
isFaceUp: true,
|
|
||||||
cardWidth: cardWidth
|
|
||||||
)
|
|
||||||
.overlay(alignment: .bottomLeading) {
|
|
||||||
if showCardCount {
|
|
||||||
HiLoCountBadge(card: hand.cards[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.zIndex(Double(index))
|
|
||||||
.transition(
|
|
||||||
showAnimations
|
|
||||||
? .asymmetric(
|
|
||||||
insertion: .offset(x: Design.DealAnimation.playerOffsetX, y: Design.DealAnimation.playerOffsetY)
|
|
||||||
.combined(with: .opacity)
|
|
||||||
.combined(with: .scale(scale: Design.Scale.slightShrink)),
|
|
||||||
removal: .scale.combined(with: .opacity)
|
|
||||||
)
|
|
||||||
: .identity
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(
|
|
||||||
showAnimations
|
|
||||||
? .spring(duration: animationDuration, bounce: Design.Animation.springBounce)
|
|
||||||
: .none,
|
|
||||||
value: hand.cards.count
|
|
||||||
)
|
)
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
@ -214,35 +120,13 @@ struct PlayerHandView: View {
|
|||||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate value from visible (animation-completed) cards
|
// Calculate value from visible (animation-completed) cards only
|
||||||
// Always show the value - it updates as cards become visible
|
if let displayInfo = visibleCardsDisplayInfo {
|
||||||
if !hand.cards.isEmpty {
|
Text(displayInfo.text)
|
||||||
// Use only the cards that have completed their animation
|
|
||||||
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
|
|
||||||
let visibleValue = visibleCards.reduce(0) { $0 + $1.blackjackValue }
|
|
||||||
let visibleHasSoftAce = visibleCards.contains { $0.rank == .ace } && visibleValue + 10 <= 21
|
|
||||||
let displayValue = visibleHasSoftAce && visibleValue + 10 <= 21 ? visibleValue + 10 : visibleValue
|
|
||||||
|
|
||||||
// Determine color based on visible cards
|
|
||||||
let isVisibleBlackjack = visibleCards.count == 2 && displayValue == 21
|
|
||||||
let isVisibleBusted = visibleValue > 21
|
|
||||||
let isVisible21 = displayValue == 21 && !isVisibleBlackjack
|
|
||||||
|
|
||||||
let displayColor: Color = {
|
|
||||||
if isVisibleBlackjack { return .yellow }
|
|
||||||
if isVisibleBusted { return .red }
|
|
||||||
if isVisible21 { return .green }
|
|
||||||
return .white
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Show value like hand.valueDisplay does
|
|
||||||
let valueText = visibleHasSoftAce ? "\(visibleValue)/\(displayValue)" : "\(displayValue)"
|
|
||||||
|
|
||||||
Text(valueText)
|
|
||||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(displayColor)
|
.foregroundStyle(displayInfo.color)
|
||||||
.animation(nil, value: valueText) // No animation when text changes
|
.animation(nil, value: displayInfo.text)
|
||||||
.animation(nil, value: displayColor) // No animation when color changes
|
.animation(nil, value: displayInfo.color)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hand.isDoubledDown {
|
if hand.isDoubledDown {
|
||||||
@ -289,80 +173,87 @@ struct PlayerHandView: View {
|
|||||||
|
|
||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|
||||||
#Preview("Single Hand - Empty") {
|
#Preview("Empty Hand") {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.Table.felt.ignoresSafeArea()
|
Color.Table.felt.ignoresSafeArea()
|
||||||
PlayerHandsView(
|
PlayerHandView(
|
||||||
hands: [BlackjackHand()],
|
hand: BlackjackHand(),
|
||||||
activeHandIndex: 0,
|
isActive: true,
|
||||||
isPlayerTurn: true,
|
|
||||||
showCardCount: false,
|
showCardCount: false,
|
||||||
showAnimations: true,
|
showAnimations: true,
|
||||||
dealingSpeed: 1.0,
|
dealingSpeed: 1.0,
|
||||||
|
handNumber: nil,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20,
|
cardSpacing: -20,
|
||||||
visibleCardCounts: [0],
|
visibleCardCount: 0,
|
||||||
currentHint: nil,
|
currentHint: nil,
|
||||||
showHintToast: false
|
showHintToast: false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("Single Hand - Cards") {
|
#Preview("With Cards") {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.Table.felt.ignoresSafeArea()
|
Color.Table.felt.ignoresSafeArea()
|
||||||
PlayerHandsView(
|
PlayerHandView(
|
||||||
hands: [BlackjackHand(cards: [
|
hand: BlackjackHand(cards: [
|
||||||
Card(suit: .clubs, rank: .eight),
|
Card(suit: .clubs, rank: .eight),
|
||||||
Card(suit: .hearts, rank: .nine)
|
Card(suit: .hearts, rank: .nine)
|
||||||
], bet: 100)],
|
], bet: 100),
|
||||||
activeHandIndex: 0,
|
isActive: true,
|
||||||
isPlayerTurn: true,
|
|
||||||
showCardCount: false,
|
showCardCount: false,
|
||||||
showAnimations: true,
|
showAnimations: true,
|
||||||
dealingSpeed: 1.0,
|
dealingSpeed: 1.0,
|
||||||
|
handNumber: nil,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20,
|
cardSpacing: -20,
|
||||||
visibleCardCounts: [2],
|
visibleCardCount: 2,
|
||||||
currentHint: "Hit",
|
currentHint: "Hit",
|
||||||
showHintToast: true
|
showHintToast: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("Split Hands") {
|
#Preview("Blackjack") {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.Table.felt.ignoresSafeArea()
|
Color.Table.felt.ignoresSafeArea()
|
||||||
PlayerHandsView(
|
PlayerHandView(
|
||||||
hands: [
|
hand: BlackjackHand(cards: [
|
||||||
BlackjackHand(cards: [
|
Card(suit: .hearts, rank: .ace),
|
||||||
Card(suit: .clubs, rank: .eight),
|
Card(suit: .spades, rank: .king)
|
||||||
Card(suit: .spades, rank: .jack)
|
], bet: 100),
|
||||||
], bet: 100),
|
isActive: false,
|
||||||
BlackjackHand(cards: [
|
|
||||||
Card(suit: .hearts, rank: .eight),
|
|
||||||
Card(suit: .diamonds, rank: .five)
|
|
||||||
], bet: 100),
|
|
||||||
BlackjackHand(cards: [
|
|
||||||
Card(suit: .hearts, rank: .eight),
|
|
||||||
Card(suit: .diamonds, rank: .five)
|
|
||||||
], bet: 100),
|
|
||||||
BlackjackHand(cards: [
|
|
||||||
Card(suit: .hearts, rank: .eight),
|
|
||||||
Card(suit: .diamonds, rank: .five)
|
|
||||||
], bet: 100)
|
|
||||||
],
|
|
||||||
activeHandIndex: 1,
|
|
||||||
isPlayerTurn: true,
|
|
||||||
showCardCount: true,
|
showCardCount: true,
|
||||||
showAnimations: true,
|
showAnimations: true,
|
||||||
dealingSpeed: 1.0,
|
dealingSpeed: 1.0,
|
||||||
|
handNumber: nil,
|
||||||
cardWidth: 60,
|
cardWidth: 60,
|
||||||
cardSpacing: -20,
|
cardSpacing: -20,
|
||||||
visibleCardCounts: [2, 2, 2, 2],
|
visibleCardCount: 2,
|
||||||
|
currentHint: nil,
|
||||||
|
showHintToast: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Split Hand") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
PlayerHandView(
|
||||||
|
hand: BlackjackHand(cards: [
|
||||||
|
Card(suit: .clubs, rank: .eight),
|
||||||
|
Card(suit: .spades, rank: .jack)
|
||||||
|
], bet: 100),
|
||||||
|
isActive: true,
|
||||||
|
showCardCount: false,
|
||||||
|
showAnimations: true,
|
||||||
|
dealingSpeed: 1.0,
|
||||||
|
handNumber: 2,
|
||||||
|
cardWidth: 60,
|
||||||
|
cardSpacing: -20,
|
||||||
|
visibleCardCount: 2,
|
||||||
currentHint: "Stand",
|
currentHint: "Stand",
|
||||||
showHintToast: true
|
showHintToast: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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