Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
4d79f08089
commit
c358d3b2ae
@ -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" : {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user