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" : {
"localizations" : {
"en" : {

View File

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

View File

@ -42,29 +42,28 @@ struct GameTableView: View {
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
var body: some View {
Group {
if let state = gameState {
mainGameView(state: state)
} else {
ProgressView()
.task {
gameState = GameState(settings: settings)
}
mainGameView(state: state)
.onAppear {
if gameState == nil {
gameState = GameState(settings: settings)
}
}
}
.sheet(isPresented: $showSettings) {
.sheet(isPresented: $showSettings) {
SettingsView(settings: settings, gameState: gameState)
}
.sheet(isPresented: $showRules) {
RulesHelpView()
}
.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
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 {
VStack(spacing: 0) {
// Dealer area
@ -99,7 +122,9 @@ struct BlackjackTableView: View {
isPlayerTurn: state.isPlayerTurn,
showCardCount: showCardCount,
cardWidth: cardWidth,
cardSpacing: cardSpacing
cardSpacing: cardSpacing,
currentHint: state.currentHint,
showHintToast: state.showHintToast
)
// Side bet toasts (positioned on left/right sides to not cover cards)
@ -132,33 +157,10 @@ struct BlackjackTableView: View {
.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
// Show toast when a new hint appears
if let hint = newHint, hint != oldHint {
// 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
}
}
}
showHintToastWithTimer(state: state)
} else if newHint == nil {
// Hide immediately when no hint
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)
.transition(.opacity)
.debugBorder(showDebugBorders, color: .green, label: "Player")

View File

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

View File

@ -14,6 +14,8 @@ struct InsurancePopupView: View {
let onTake: () -> Void
let onDecline: () -> Void
@State private var showContent = false
var body: some View {
ZStack {
// Dimmed background
@ -91,6 +93,13 @@ struct InsurancePopupView: View {
RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge)
.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)
.accessibilityAddTraits(.isModal)

View File

@ -19,6 +19,12 @@ struct PlayerHandsView: View {
let cardWidth: 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
private var totalCardCount: Int {
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)
// 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
PlayerHandView(
hand: hand,
isActive: index == activeHandIndex && isPlayerTurn,
isActive: isActiveHand,
showCardCount: showCardCount,
// Hand numbers: rightmost (index 0) is Hand 1, played first
handNumber: hands.count > 1 ? index + 1 : nil,
cardWidth: cardWidth,
cardSpacing: cardSpacing
cardSpacing: cardSpacing,
// Only show hint on the active hand
currentHint: isActiveHand ? currentHint : nil,
showHintToast: isActiveHand && showHintToast
)
.id(hand.id)
.transition(.scale.combined(with: .opacity))
@ -89,6 +99,12 @@ struct PlayerHandView: View {
let cardWidth: 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: .caption) private var handNumberSize: CGFloat = Design.Size.handNumberFontSize
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.Size.handIconSize
@ -132,7 +148,7 @@ struct PlayerHandView: View {
)
)
.overlay {
// Result badge - centered on cards
// Result badge - centered on cards (takes priority over hint)
if let result = hand.result {
Text(result.displayText)
.font(.system(size: labelFontSize, weight: .black))
@ -145,6 +161,10 @@ struct PlayerHandView: View {
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
)
.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())
@ -218,7 +238,9 @@ struct PlayerHandView: View {
isPlayerTurn: true,
showCardCount: false,
cardWidth: 60,
cardSpacing: -20
cardSpacing: -20,
currentHint: nil,
showHintToast: false
)
}
}
@ -235,7 +257,9 @@ struct PlayerHandView: View {
isPlayerTurn: true,
showCardCount: false,
cardWidth: 60,
cardSpacing: -20
cardSpacing: -20,
currentHint: "Hit",
showHintToast: true
)
}
}
@ -266,7 +290,9 @@ struct PlayerHandView: View {
isPlayerTurn: true,
showCardCount: true,
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 {
let token = FileManager.default.ubiquityIdentityToken
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
}
@ -120,30 +120,30 @@ public final class CloudSyncManager<T: PersistableGameData> {
/// Checks for iCloud data after a brief delay (for fresh installs).
private func scheduleDelayedCloudCheck() {
print("CloudSyncManager[\(T.gameIdentifier)]: Scheduling delayed cloud check...")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Scheduling delayed cloud check...")
Task { @MainActor in
// Wait for iCloud to sync (typically takes 1-2 seconds on fresh install)
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)
var syncResult = false
await MainActor.run {
syncResult = iCloudStore.synchronize()
}
print("CloudSyncManager[\(T.gameIdentifier)]: synchronize() returned \(syncResult)")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: synchronize() returned \(syncResult)")
// Check what's in the store
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
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 {
print("CloudSyncManager[\(T.gameIdentifier)]: Cloud has more data, updating...")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Cloud has more data, updating...")
data = cloudData
hasCompletedInitialSync = true
onCloudDataReceived?(cloudData)
@ -160,11 +160,11 @@ public final class CloudSyncManager<T: PersistableGameData> {
userInfo: ["gameIdentifier": T.gameIdentifier]
)
} 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 {
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
guard let encoded = try? encoder.encode(dataToSave) else {
print("CloudSyncManager: Failed to encode game data")
CasinoDesign.debugLog("CloudSyncManager: Failed to encode game data")
return
}
@ -194,7 +194,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
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.
@ -216,31 +216,31 @@ public final class CloudSyncManager<T: PersistableGameData> {
switch (localData, cloudData) {
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
case (let local?, nil):
print("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
finalData = local
case (nil, let cloud?):
print("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud data")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using iCloud data")
finalData = cloud
case (let local?, let cloud?):
// Use whichever has more rounds played
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
// Update local with cloud data
if let encoded = try? encoder.encode(cloud) {
UserDefaults.standard.set(encoded, forKey: localKey)
}
} 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
} else {
print("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Using local data")
finalData = local
}
}
@ -305,7 +305,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
switch reason {
case NSUbiquitousKeyValueStoreServerChange,
NSUbiquitousKeyValueStoreInitialSyncChange:
print("CloudSyncManager[\(T.gameIdentifier)]: Data changed from another device")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Data changed from another device")
syncStatus = "Received update"
// Reload and notify
@ -327,11 +327,11 @@ public final class CloudSyncManager<T: PersistableGameData> {
}
case NSUbiquitousKeyValueStoreQuotaViolationChange:
print("CloudSyncManager[\(T.gameIdentifier)]: iCloud quota exceeded")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud quota exceeded")
syncStatus = "Storage full"
case NSUbiquitousKeyValueStoreAccountChange:
print("CloudSyncManager[\(T.gameIdentifier)]: iCloud account changed")
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud account changed")
syncStatus = "Account changed"
// Reload with new account
data = load()
@ -355,7 +355,7 @@ public final class CloudSyncManager<T: PersistableGameData> {
data = T.empty
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.
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
public enum Spacing {