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

This commit is contained in:
Matt Bruce 2025-12-24 14:50:58 -06:00
parent 4d79f08089
commit c358d3b2ae
9 changed files with 141 additions and 88 deletions

View File

@ -3480,28 +3480,6 @@
} }
} }
}, },
"Hint: %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hint: %@"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Consejo: %@"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conseil: %@"
}
}
}
},
"Hit" : { "Hit" : {
"localizations" : { "localizations" : {
"en" : { "en" : {

View File

@ -62,6 +62,7 @@ enum Design {
static let hintIconSize: CGFloat = 24 static let hintIconSize: CGFloat = 24
static let hintPaddingH: CGFloat = 10 static let hintPaddingH: CGFloat = 10
static let hintPaddingV: CGFloat = 10 static let hintPaddingV: CGFloat = 10
static let hintMinWidth: CGFloat = 90
// Hand icons // Hand icons
static let handIconSize: CGFloat = 18 static let handIconSize: CGFloat = 18

View File

@ -42,29 +42,28 @@ struct GameTableView: View {
return .infinity return .infinity
} }
/// Provides the current game state, creating one if needed (fallback for initial render).
private var state: GameState {
gameState ?? GameState(settings: settings)
}
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
Group { mainGameView(state: state)
if let state = gameState { .onAppear {
mainGameView(state: state) if gameState == nil {
} else { gameState = GameState(settings: settings)
ProgressView() }
.task {
gameState = GameState(settings: settings)
}
} }
} .sheet(isPresented: $showSettings) {
.sheet(isPresented: $showSettings) {
SettingsView(settings: settings, gameState: gameState) SettingsView(settings: settings, gameState: gameState)
} }
.sheet(isPresented: $showRules) { .sheet(isPresented: $showRules) {
RulesHelpView() RulesHelpView()
} }
.sheet(isPresented: $showStats) { .sheet(isPresented: $showStats) {
if let state = gameState { StatisticsSheetView(state: state)
StatisticsSheetView(state: state)
}
} }
} }

View File

@ -61,6 +61,29 @@ struct BlackjackTableView: View {
// Use global debug flag from Design constants // Use global debug flag from Design constants
private var showDebugBorders: Bool { Design.showDebugBorders } private var showDebugBorders: Bool { Design.showDebugBorders }
// MARK: - Hint Toast Helper
/// Shows the hint toast with auto-dismiss timer.
private func showHintToastWithTimer(state: GameState) {
// Generate new ID to invalidate any pending dismiss tasks
let currentID = UUID()
state.hintDisplayID = currentID
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = true
}
// Auto-dismiss after delay, but only if this is still the active hint session
Task { @MainActor in
try? await Task.sleep(for: Design.Toast.duration)
// Only dismiss if no newer hint has arrived
if state.hintDisplayID == currentID {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = false
}
}
}
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Dealer area // Dealer area
@ -99,7 +122,9 @@ struct BlackjackTableView: View {
isPlayerTurn: state.isPlayerTurn, isPlayerTurn: state.isPlayerTurn,
showCardCount: showCardCount, showCardCount: showCardCount,
cardWidth: cardWidth, cardWidth: cardWidth,
cardSpacing: cardSpacing cardSpacing: cardSpacing,
currentHint: state.currentHint,
showHintToast: state.showHintToast
) )
// Side bet toasts (positioned on left/right sides to not cover cards) // Side bet toasts (positioned on left/right sides to not cover cards)
@ -132,33 +157,10 @@ struct BlackjackTableView: View {
.padding(.horizontal, Design.Spacing.small) .padding(.horizontal, Design.Spacing.small)
} }
} }
.overlay {
// Hint toast overlaying player hands (auto-dismisses)
if state.showHintToast, let hint = state.currentHint {
HintView(hint: hint)
.transition(.scale.combined(with: .opacity))
}
}
.onChange(of: state.currentHint) { oldHint, newHint in .onChange(of: state.currentHint) { oldHint, newHint in
// Show toast when a new hint appears // Show toast when a new hint appears
if let hint = newHint, hint != oldHint { if let hint = newHint, hint != oldHint {
// Generate new ID to invalidate any pending dismiss tasks showHintToastWithTimer(state: state)
let currentID = UUID()
state.hintDisplayID = currentID
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = true
}
// Auto-dismiss after delay, but only if this is still the active hint session
Task { @MainActor in
try? await Task.sleep(for: Design.Toast.duration)
// Only dismiss if no newer hint has arrived
if state.hintDisplayID == currentID {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = false
}
}
}
} else if newHint == nil { } else if newHint == nil {
// Hide immediately when no hint // Hide immediately when no hint
state.hintDisplayID = UUID() // Invalidate any pending dismiss tasks state.hintDisplayID = UUID() // Invalidate any pending dismiss tasks
@ -167,6 +169,29 @@ struct BlackjackTableView: View {
} }
} }
} }
.onChange(of: state.playerHands.count) { _, _ in
// Show hint when hands are added (split occurred)
if state.currentHint != nil {
showHintToastWithTimer(state: state)
}
}
.onChange(of: state.activeHandIndex) { _, _ in
// Show hint when active hand changes (moved to next hand after split)
if state.currentHint != nil {
showHintToastWithTimer(state: state)
}
}
.onChange(of: state.activeHand?.cards.count) { _, newCount in
// Show hint when a card is added to the active hand (after hit)
guard let count = newCount, count > 2, state.currentHint != nil else { return }
// Small delay to let card animation settle before showing hint
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(250))
if state.currentHint != nil {
showHintToastWithTimer(state: state)
}
}
}
.padding(.bottom, 5) .padding(.bottom, 5)
.transition(.opacity) .transition(.opacity)
.debugBorder(showDebugBorders, color: .green, label: "Player") .debugBorder(showDebugBorders, color: .green, label: "Player")

View File

@ -24,7 +24,7 @@ struct HintView: View {
Image(systemName: "lightbulb.fill") Image(systemName: "lightbulb.fill")
.font(.system(size: iconSize)) .font(.system(size: iconSize))
.foregroundStyle(.yellow) .foregroundStyle(.yellow)
Text(String(localized: "Hint: \(hint)")) Text(hint)
.font(.system(size: fontSize, weight: .medium)) .font(.system(size: fontSize, weight: .medium))
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(1) .lineLimit(1)
@ -32,6 +32,7 @@ struct HintView: View {
} }
.padding(.horizontal, paddingH) .padding(.horizontal, paddingH)
.padding(.vertical, paddingV) .padding(.vertical, paddingV)
.frame(minWidth: Design.Size.hintMinWidth, alignment: .leading)
.background( .background(
Capsule() Capsule()
.fill(Color.black.opacity(Design.Opacity.heavy)) .fill(Color.black.opacity(Design.Opacity.heavy))

View File

@ -14,6 +14,8 @@ struct InsurancePopupView: View {
let onTake: () -> Void let onTake: () -> Void
let onDecline: () -> Void let onDecline: () -> Void
@State private var showContent = false
var body: some View { var body: some View {
ZStack { ZStack {
// Dimmed background // Dimmed background
@ -91,6 +93,13 @@ struct InsurancePopupView: View {
RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge) RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge)
.strokeBorder(Color.yellow.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin) .strokeBorder(Color.yellow.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
) )
.scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink)
.opacity(showContent ? 1.0 : 0)
}
.onAppear {
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) {
showContent = true
}
} }
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
.accessibilityAddTraits(.isModal) .accessibilityAddTraits(.isModal)

View File

@ -19,6 +19,12 @@ struct PlayerHandsView: View {
let cardWidth: CGFloat let cardWidth: CGFloat
let cardSpacing: CGFloat let cardSpacing: CGFloat
/// 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 /// Total card count across all hands - used to trigger scroll when hitting
private var totalCardCount: Int { private var totalCardCount: Int {
hands.reduce(0) { $0 + $1.cards.count } hands.reduce(0) { $0 + $1.cards.count }
@ -32,14 +38,18 @@ struct PlayerHandsView: View {
// Visual order: Hand 3, Hand 2, Hand 1 (left to right) // Visual order: Hand 3, Hand 2, Hand 1 (left to right)
// Play order: Hand 1 played first (rightmost), then Hand 2, etc. // Play order: Hand 1 played first (rightmost), then Hand 2, etc.
ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in
let isActiveHand = index == activeHandIndex && isPlayerTurn
PlayerHandView( PlayerHandView(
hand: hand, hand: hand,
isActive: index == activeHandIndex && isPlayerTurn, isActive: isActiveHand,
showCardCount: showCardCount, showCardCount: showCardCount,
// Hand numbers: rightmost (index 0) is Hand 1, played first // Hand numbers: rightmost (index 0) is Hand 1, played first
handNumber: hands.count > 1 ? index + 1 : nil, handNumber: hands.count > 1 ? index + 1 : nil,
cardWidth: cardWidth, cardWidth: cardWidth,
cardSpacing: cardSpacing cardSpacing: cardSpacing,
// Only show hint on the active hand
currentHint: isActiveHand ? currentHint : nil,
showHintToast: isActiveHand && showHintToast
) )
.id(hand.id) .id(hand.id)
.transition(.scale.combined(with: .opacity)) .transition(.scale.combined(with: .opacity))
@ -89,6 +99,12 @@ struct PlayerHandView: View {
let cardWidth: CGFloat let cardWidth: CGFloat
let cardSpacing: CGFloat let cardSpacing: CGFloat
/// Current hint to display on this hand.
let currentHint: String?
/// Whether the hint toast should be visible.
let showHintToast: Bool
@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
@ -132,7 +148,7 @@ struct PlayerHandView: View {
) )
) )
.overlay { .overlay {
// Result badge - centered on cards // Result badge - centered on cards (takes priority over hint)
if let result = hand.result { if let result = hand.result {
Text(result.displayText) Text(result.displayText)
.font(.system(size: labelFontSize, weight: .black)) .font(.system(size: labelFontSize, weight: .black))
@ -145,6 +161,10 @@ struct PlayerHandView: View {
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium) .shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
) )
.transition(.scale.combined(with: .opacity)) .transition(.scale.combined(with: .opacity))
} else if showHintToast, let hint = currentHint {
// Hint toast - centered on active hand (only when no result)
HintView(hint: hint)
.transition(.scale.combined(with: .opacity))
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
@ -218,7 +238,9 @@ struct PlayerHandView: View {
isPlayerTurn: true, isPlayerTurn: true,
showCardCount: false, showCardCount: false,
cardWidth: 60, cardWidth: 60,
cardSpacing: -20 cardSpacing: -20,
currentHint: nil,
showHintToast: false
) )
} }
} }
@ -235,7 +257,9 @@ struct PlayerHandView: View {
isPlayerTurn: true, isPlayerTurn: true,
showCardCount: false, showCardCount: false,
cardWidth: 60, cardWidth: 60,
cardSpacing: -20 cardSpacing: -20,
currentHint: "Hit",
showHintToast: true
) )
} }
} }
@ -266,7 +290,9 @@ struct PlayerHandView: View {
isPlayerTurn: true, isPlayerTurn: true,
showCardCount: true, showCardCount: true,
cardWidth: 60, cardWidth: 60,
cardSpacing: -20 cardSpacing: -20,
currentHint: "Stand",
showHintToast: true
) )
} }
} }

View File

@ -37,7 +37,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
public var iCloudAvailable: Bool { public var iCloudAvailable: Bool {
let token = FileManager.default.ubiquityIdentityToken let token = FileManager.default.ubiquityIdentityToken
let available = token != nil let available = token != nil
print("CloudSyncManager: iCloud available = \(available), token = \(String(describing: token))") CasinoDesign.debugLog("CloudSyncManager: iCloud available = \(available), token = \(String(describing: token))")
return available return available
} }
@ -120,30 +120,30 @@ public final class CloudSyncManager<T: PersistableGameData> {
/// Checks for iCloud data after a brief delay (for fresh installs). /// Checks for iCloud data after a brief delay (for fresh installs).
private func scheduleDelayedCloudCheck() { private func scheduleDelayedCloudCheck() {
print("CloudSyncManager[\(T.gameIdentifier)]: Scheduling delayed cloud check...") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Scheduling delayed cloud check...")
Task { @MainActor in Task { @MainActor in
// Wait for iCloud to sync (typically takes 1-2 seconds on fresh install) // Wait for iCloud to sync (typically takes 1-2 seconds on fresh install)
try? await Task.sleep(for: .seconds(2)) try? await Task.sleep(for: .seconds(2))
print("CloudSyncManager[\(T.gameIdentifier)]: Delayed check - forcing sync...") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Delayed check - forcing sync...")
// Force another sync (on main thread to avoid concurrency warning) // Force another sync (on main thread to avoid concurrency warning)
var syncResult = false var syncResult = false
await MainActor.run { await MainActor.run {
syncResult = iCloudStore.synchronize() syncResult = iCloudStore.synchronize()
} }
print("CloudSyncManager[\(T.gameIdentifier)]: synchronize() returned \(syncResult)") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: synchronize() returned \(syncResult)")
// Check what's in the store // Check what's in the store
let allKeys = iCloudStore.dictionaryRepresentation.keys let allKeys = iCloudStore.dictionaryRepresentation.keys
print("CloudSyncManager[\(T.gameIdentifier)]: iCloud store keys: \(Array(allKeys))") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud store keys: \(Array(allKeys))")
// Try loading cloud data again // Try loading cloud data again
if let cloudData = loadCloud() { if let cloudData = loadCloud() {
print("CloudSyncManager[\(T.gameIdentifier)]: Found cloud data with \(cloudData.roundsPlayed) rounds") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Found cloud data with \(cloudData.roundsPlayed) rounds")
if cloudData.roundsPlayed > data.roundsPlayed { if cloudData.roundsPlayed > data.roundsPlayed {
print("CloudSyncManager[\(T.gameIdentifier)]: Cloud has more data, updating...") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Cloud has more data, updating...")
data = cloudData data = cloudData
hasCompletedInitialSync = true hasCompletedInitialSync = true
onCloudDataReceived?(cloudData) onCloudDataReceived?(cloudData)
@ -160,11 +160,11 @@ public final class CloudSyncManager<T: PersistableGameData> {
userInfo: ["gameIdentifier": T.gameIdentifier] userInfo: ["gameIdentifier": T.gameIdentifier]
) )
} else { } else {
print("CloudSyncManager[\(T.gameIdentifier)]: Local data has same or more rounds") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Local data has same or more rounds")
} }
} else { } else {
hasCompletedInitialSync = true hasCompletedInitialSync = true
print("CloudSyncManager[\(T.gameIdentifier)]: No cloud data found after delay") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: No cloud data found after delay")
} }
} }
} }
@ -178,7 +178,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
self.data = dataToSave self.data = dataToSave
guard let encoded = try? encoder.encode(dataToSave) else { guard let encoded = try? encoder.encode(dataToSave) else {
print("CloudSyncManager: Failed to encode game data") CasinoDesign.debugLog("CloudSyncManager: Failed to encode game data")
return return
} }
@ -194,7 +194,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
syncStatus = "Synced" syncStatus = "Synced"
} }
print("CloudSyncManager[\(T.gameIdentifier)]: Saved (rounds: \(dataToSave.roundsPlayed))") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Saved (rounds: \(dataToSave.roundsPlayed))")
} }
/// Convenience to update and save in one call. /// Convenience to update and save in one call.
@ -216,31 +216,31 @@ public final class CloudSyncManager<T: PersistableGameData> {
switch (localData, cloudData) { switch (localData, cloudData) {
case (nil, nil): case (nil, nil):
print("CloudSyncManager[\(T.gameIdentifier)]: No saved data, using empty") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: No saved data, using empty")
finalData = T.empty finalData = T.empty
case (let local?, nil): case (let local?, nil):
print("CloudSyncManager[\(T.gameIdentifier)]: Using local data") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
finalData = local finalData = local
case (nil, let cloud?): case (nil, let cloud?):
print("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud data") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud data")
finalData = cloud finalData = cloud
case (let local?, let cloud?): case (let local?, let cloud?):
// Use whichever has more rounds played // Use whichever has more rounds played
if cloud.roundsPlayed > local.roundsPlayed { if cloud.roundsPlayed > local.roundsPlayed {
print("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud (more progress: \(cloud.roundsPlayed) vs \(local.roundsPlayed))") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud (more progress: \(cloud.roundsPlayed) vs \(local.roundsPlayed))")
finalData = cloud finalData = cloud
// Update local with cloud data // Update local with cloud data
if let encoded = try? encoder.encode(cloud) { if let encoded = try? encoder.encode(cloud) {
UserDefaults.standard.set(encoded, forKey: localKey) UserDefaults.standard.set(encoded, forKey: localKey)
} }
} else if local.lastModified > cloud.lastModified { } else if local.lastModified > cloud.lastModified {
print("CloudSyncManager[\(T.gameIdentifier)]: Using local (newer: \(local.lastModified))") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using local (newer: \(local.lastModified))")
finalData = local finalData = local
} else { } else {
print("CloudSyncManager[\(T.gameIdentifier)]: Using local data") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
finalData = local finalData = local
} }
} }
@ -305,7 +305,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
switch reason { switch reason {
case NSUbiquitousKeyValueStoreServerChange, case NSUbiquitousKeyValueStoreServerChange,
NSUbiquitousKeyValueStoreInitialSyncChange: NSUbiquitousKeyValueStoreInitialSyncChange:
print("CloudSyncManager[\(T.gameIdentifier)]: Data changed from another device") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Data changed from another device")
syncStatus = "Received update" syncStatus = "Received update"
// Reload and notify // Reload and notify
@ -327,11 +327,11 @@ public final class CloudSyncManager<T: PersistableGameData> {
} }
case NSUbiquitousKeyValueStoreQuotaViolationChange: case NSUbiquitousKeyValueStoreQuotaViolationChange:
print("CloudSyncManager[\(T.gameIdentifier)]: iCloud quota exceeded") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud quota exceeded")
syncStatus = "Storage full" syncStatus = "Storage full"
case NSUbiquitousKeyValueStoreAccountChange: case NSUbiquitousKeyValueStoreAccountChange:
print("CloudSyncManager[\(T.gameIdentifier)]: iCloud account changed") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud account changed")
syncStatus = "Account changed" syncStatus = "Account changed"
// Reload with new account // Reload with new account
data = load() data = load()
@ -355,7 +355,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
data = T.empty data = T.empty
syncStatus = "Data cleared" syncStatus = "Data cleared"
print("CloudSyncManager[\(T.gameIdentifier)]: All data cleared") CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: All data cleared")
} }
} }

View File

@ -10,6 +10,20 @@ import SwiftUI
/// Shared design constants for casino game components. /// Shared design constants for casino game components.
public enum CasinoDesign { public enum CasinoDesign {
// MARK: - Debug
/// Set to true to enable debug logging in CasinoKit.
public static let showDebugLogs = false
/// Logs a message only in debug builds when `showDebugLogs` is enabled.
public static func debugLog(_ message: String) {
#if DEBUG
if showDebugLogs {
print(message)
}
#endif
}
// MARK: - Spacing // MARK: - Spacing
public enum Spacing { public enum Spacing {