Compare commits
3 Commits
d16bf6eb2e
...
1daaf6ca22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1daaf6ca22 | ||
|
|
53edd3aa7c | ||
|
|
f1c579244c |
@ -188,7 +188,7 @@
|
|||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2600;
|
LastSwiftUpdateCheck = 2600;
|
||||||
LastUpgradeCheck = 2600;
|
LastUpgradeCheck = 2610;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
EAD890B62EF1E9CE006DBA80 = {
|
EAD890B62EF1E9CE006DBA80 = {
|
||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
@ -296,6 +296,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@ -350,6 +351,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@ -361,6 +363,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@ -408,6 +411,7 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
|
|||||||
@ -51,6 +51,9 @@ final class GameState {
|
|||||||
// MARK: - Settings
|
// MARK: - Settings
|
||||||
let settings: GameSettings
|
let settings: GameSettings
|
||||||
|
|
||||||
|
// MARK: - Onboarding
|
||||||
|
let onboarding: OnboardingState
|
||||||
|
|
||||||
// MARK: - Sound
|
// MARK: - Sound
|
||||||
private let sound = SoundManager.shared
|
private let sound = SoundManager.shared
|
||||||
|
|
||||||
@ -343,6 +346,7 @@ final class GameState {
|
|||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.engine = BaccaratEngine(deckCount: settings.deckCount.rawValue)
|
self.engine = BaccaratEngine(deckCount: settings.deckCount.rawValue)
|
||||||
self.balance = settings.startingBalance
|
self.balance = settings.startingBalance
|
||||||
|
self.onboarding = OnboardingState(gameIdentifier: "baccarat")
|
||||||
|
|
||||||
// Sync sound settings with SoundManager
|
// Sync sound settings with SoundManager
|
||||||
syncSoundSettings()
|
syncSoundSettings()
|
||||||
|
|||||||
@ -100,7 +100,7 @@ final class GameSettings {
|
|||||||
// MARK: - Betting Limits
|
// MARK: - Betting Limits
|
||||||
|
|
||||||
/// The table limits preset.
|
/// The table limits preset.
|
||||||
var tableLimits: TableLimits = .low
|
var tableLimits: TableLimits = .casual
|
||||||
|
|
||||||
/// Minimum bet amount.
|
/// Minimum bet amount.
|
||||||
var minBet: Int {
|
var minBet: Int {
|
||||||
@ -115,7 +115,7 @@ final class GameSettings {
|
|||||||
// MARK: - Starting Balance
|
// MARK: - Starting Balance
|
||||||
|
|
||||||
/// The starting balance for new games.
|
/// The starting balance for new games.
|
||||||
var startingBalance: Int = 10_000
|
var startingBalance: Int = 1_000
|
||||||
|
|
||||||
// MARK: - Animation Settings
|
// MARK: - Animation Settings
|
||||||
|
|
||||||
@ -165,10 +165,31 @@ final class GameSettings {
|
|||||||
|
|
||||||
// MARK: - iCloud
|
// MARK: - iCloud
|
||||||
|
|
||||||
private let iCloudStore = NSUbiquitousKeyValueStore.default
|
/// Cached reference to iCloud store, lazily initialized
|
||||||
|
private var _iCloudStore: NSUbiquitousKeyValueStore?
|
||||||
|
private var _iCloudStoreInitialized = false
|
||||||
|
|
||||||
|
private var iCloudStore: NSUbiquitousKeyValueStore? {
|
||||||
|
// Return cached value if already attempted initialization
|
||||||
|
if _iCloudStoreInitialized {
|
||||||
|
return _iCloudStore
|
||||||
|
}
|
||||||
|
|
||||||
|
_iCloudStoreInitialized = true
|
||||||
|
|
||||||
|
// Only access the store if iCloud is actually available
|
||||||
|
guard iCloudAvailable else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_iCloudStore = NSUbiquitousKeyValueStore.default
|
||||||
|
return _iCloudStore
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether iCloud is available.
|
/// Whether iCloud is available.
|
||||||
var iCloudAvailable: Bool {
|
var iCloudAvailable: Bool {
|
||||||
|
// NSUbiquitousKeyValueStore only requires iCloud sign-in (token)
|
||||||
|
// It does NOT require iCloud Drive/Documents to be enabled
|
||||||
FileManager.default.ubiquityIdentityToken != nil
|
FileManager.default.ubiquityIdentityToken != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,20 +198,19 @@ final class GameSettings {
|
|||||||
init() {
|
init() {
|
||||||
load()
|
load()
|
||||||
|
|
||||||
// Register for iCloud changes
|
// Register for iCloud changes (only if available)
|
||||||
NotificationCenter.default.addObserver(
|
if iCloudAvailable, let store = iCloudStore {
|
||||||
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
NotificationCenter.default.addObserver(
|
||||||
object: iCloudStore,
|
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
||||||
queue: .main
|
object: store,
|
||||||
) { [weak self] _ in
|
queue: .main
|
||||||
Task { @MainActor in
|
) { [weak self] _ in
|
||||||
|
// Already on main queue, safe to call
|
||||||
self?.loadFromiCloud()
|
self?.loadFromiCloud()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger iCloud sync
|
// Trigger iCloud sync
|
||||||
if iCloudAvailable {
|
store.synchronize()
|
||||||
iCloudStore.synchronize()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -260,51 +280,51 @@ final class GameSettings {
|
|||||||
|
|
||||||
/// Loads settings from iCloud.
|
/// Loads settings from iCloud.
|
||||||
private func loadFromiCloud() {
|
private func loadFromiCloud() {
|
||||||
guard iCloudAvailable else { return }
|
guard iCloudAvailable, let store = iCloudStore else { return }
|
||||||
|
|
||||||
if let rawDeckCount = iCloudStore.object(forKey: Keys.deckCount) as? Int,
|
if let rawDeckCount = store.object(forKey: Keys.deckCount) as? Int,
|
||||||
let deckCount = DeckCount(rawValue: rawDeckCount) {
|
let deckCount = DeckCount(rawValue: rawDeckCount) {
|
||||||
self.deckCount = deckCount
|
self.deckCount = deckCount
|
||||||
}
|
}
|
||||||
|
|
||||||
if let rawTableLimits = iCloudStore.string(forKey: Keys.tableLimits),
|
if let rawTableLimits = store.string(forKey: Keys.tableLimits),
|
||||||
let tableLimits = TableLimits(rawValue: rawTableLimits) {
|
let tableLimits = TableLimits(rawValue: rawTableLimits) {
|
||||||
self.tableLimits = tableLimits
|
self.tableLimits = tableLimits
|
||||||
}
|
}
|
||||||
|
|
||||||
if let balance = iCloudStore.object(forKey: Keys.startingBalance) as? Int {
|
if let balance = store.object(forKey: Keys.startingBalance) as? Int {
|
||||||
self.startingBalance = balance
|
self.startingBalance = balance
|
||||||
}
|
}
|
||||||
|
|
||||||
if iCloudStore.object(forKey: Keys.showAnimations) != nil {
|
if store.object(forKey: Keys.showAnimations) != nil {
|
||||||
self.showAnimations = iCloudStore.bool(forKey: Keys.showAnimations)
|
self.showAnimations = store.bool(forKey: Keys.showAnimations)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let speed = iCloudStore.object(forKey: Keys.dealingSpeed) as? Double {
|
if let speed = store.object(forKey: Keys.dealingSpeed) as? Double {
|
||||||
self.dealingSpeed = speed
|
self.dealingSpeed = speed
|
||||||
}
|
}
|
||||||
|
|
||||||
if iCloudStore.object(forKey: Keys.showCardsRemaining) != nil {
|
if store.object(forKey: Keys.showCardsRemaining) != nil {
|
||||||
self.showCardsRemaining = iCloudStore.bool(forKey: Keys.showCardsRemaining)
|
self.showCardsRemaining = store.bool(forKey: Keys.showCardsRemaining)
|
||||||
}
|
}
|
||||||
|
|
||||||
if iCloudStore.object(forKey: Keys.showHistory) != nil {
|
if store.object(forKey: Keys.showHistory) != nil {
|
||||||
self.showHistory = iCloudStore.bool(forKey: Keys.showHistory)
|
self.showHistory = store.bool(forKey: Keys.showHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
if iCloudStore.object(forKey: Keys.showHints) != nil {
|
if store.object(forKey: Keys.showHints) != nil {
|
||||||
self.showHints = iCloudStore.bool(forKey: Keys.showHints)
|
self.showHints = store.bool(forKey: Keys.showHints)
|
||||||
}
|
}
|
||||||
|
|
||||||
if iCloudStore.object(forKey: Keys.soundEnabled) != nil {
|
if store.object(forKey: Keys.soundEnabled) != nil {
|
||||||
self.soundEnabled = iCloudStore.bool(forKey: Keys.soundEnabled)
|
self.soundEnabled = store.bool(forKey: Keys.soundEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
if iCloudStore.object(forKey: Keys.hapticsEnabled) != nil {
|
if store.object(forKey: Keys.hapticsEnabled) != nil {
|
||||||
self.hapticsEnabled = iCloudStore.bool(forKey: Keys.hapticsEnabled)
|
self.hapticsEnabled = store.bool(forKey: Keys.hapticsEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let volume = iCloudStore.object(forKey: Keys.soundVolume) as? Double {
|
if let volume = store.object(forKey: Keys.soundVolume) as? Double {
|
||||||
self.soundVolume = Float(volume)
|
self.soundVolume = Float(volume)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -326,27 +346,27 @@ final class GameSettings {
|
|||||||
defaults.set(soundVolume, forKey: Keys.soundVolume)
|
defaults.set(soundVolume, forKey: Keys.soundVolume)
|
||||||
|
|
||||||
// Also save to iCloud
|
// Also save to iCloud
|
||||||
if iCloudAvailable {
|
if iCloudAvailable, let store = iCloudStore {
|
||||||
iCloudStore.set(deckCount.rawValue, forKey: Keys.deckCount)
|
store.set(deckCount.rawValue, forKey: Keys.deckCount)
|
||||||
iCloudStore.set(tableLimits.rawValue, forKey: Keys.tableLimits)
|
store.set(tableLimits.rawValue, forKey: Keys.tableLimits)
|
||||||
iCloudStore.set(startingBalance, forKey: Keys.startingBalance)
|
store.set(startingBalance, forKey: Keys.startingBalance)
|
||||||
iCloudStore.set(showAnimations, forKey: Keys.showAnimations)
|
store.set(showAnimations, forKey: Keys.showAnimations)
|
||||||
iCloudStore.set(dealingSpeed, forKey: Keys.dealingSpeed)
|
store.set(dealingSpeed, forKey: Keys.dealingSpeed)
|
||||||
iCloudStore.set(showCardsRemaining, forKey: Keys.showCardsRemaining)
|
store.set(showCardsRemaining, forKey: Keys.showCardsRemaining)
|
||||||
iCloudStore.set(showHistory, forKey: Keys.showHistory)
|
store.set(showHistory, forKey: Keys.showHistory)
|
||||||
iCloudStore.set(showHints, forKey: Keys.showHints)
|
store.set(showHints, forKey: Keys.showHints)
|
||||||
iCloudStore.set(soundEnabled, forKey: Keys.soundEnabled)
|
store.set(soundEnabled, forKey: Keys.soundEnabled)
|
||||||
iCloudStore.set(hapticsEnabled, forKey: Keys.hapticsEnabled)
|
store.set(hapticsEnabled, forKey: Keys.hapticsEnabled)
|
||||||
iCloudStore.set(Double(soundVolume), forKey: Keys.soundVolume)
|
store.set(Double(soundVolume), forKey: Keys.soundVolume)
|
||||||
iCloudStore.synchronize()
|
store.synchronize()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resets all settings to defaults.
|
/// Resets all settings to defaults.
|
||||||
func resetToDefaults() {
|
func resetToDefaults() {
|
||||||
deckCount = .eight
|
deckCount = .eight
|
||||||
tableLimits = .low
|
tableLimits = .casual
|
||||||
startingBalance = 10_000
|
startingBalance = 1_000
|
||||||
showAnimations = true
|
showAnimations = true
|
||||||
dealingSpeed = 1.0
|
dealingSpeed = 1.0
|
||||||
showCardsRemaining = true
|
showCardsRemaining = true
|
||||||
|
|||||||
@ -1011,6 +1011,9 @@
|
|||||||
"Banker running hot (%lld%%)" : {
|
"Banker running hot (%lld%%)" : {
|
||||||
"comment" : "A hint to place a bet on the Banker based on the calculated house edge. The argument is the percentage of the house edge.",
|
"comment" : "A hint to place a bet on the Banker based on the calculated house edge. The argument is the percentage of the house edge.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Bet on Player, Banker, or Tie" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Bet on which hand will win: Player, Banker, or Tie." : {
|
"Bet on which hand will win: Player, Banker, or Tie." : {
|
||||||
"comment" : "Text describing the objective of the baccarat game.",
|
"comment" : "Text describing the objective of the baccarat game.",
|
||||||
@ -1061,6 +1064,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Blue Circle (P): Player won the hand" : {
|
||||||
|
"comment" : "Explains the blue circle icon in the history.",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Blue Circle (P): Player won the hand"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Círculo Azul (P): El jugador ganó la mano"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Cercle Bleu (P) : Le joueur a gagné la main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"BONUS" : {
|
"BONUS" : {
|
||||||
"comment" : "The text displayed in the center of the bonus zone.",
|
"comment" : "The text displayed in the center of the bonus zone.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1198,6 +1224,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Change table limits and display options" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Chips, cards, and result sounds" : {
|
"Chips, cards, and result sounds" : {
|
||||||
"comment" : "Subtitle describing sound effects toggle.",
|
"comment" : "Subtitle describing sound effects toggle.",
|
||||||
@ -1314,6 +1343,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Customize Settings" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"DATA" : {
|
"DATA" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1712,6 +1744,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Green Circle (T): Tie between Player and Banker" : {
|
||||||
|
"comment" : "Explains the green circle icon in the history.",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Green Circle (T): Tie between Player and Banker"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Círculo Verde (T): Empate entre el jugador y el banquero"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Cercle Vert (T) : Égalité entre le joueur et le banquier"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Hand values use only the last digit (e.g., 7+8=15 → 5)." : {
|
"Hand values use only the last digit (e.g., 7+8=15 → 5)." : {
|
||||||
"comment" : "Explanation of how card values are determined in baccarat.",
|
"comment" : "Explanation of how card values are determined in baccarat.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1804,6 +1859,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"History Display" : {
|
||||||
|
"comment" : "Title of a section in the Rules Help view explaining the history feature.",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "History Display"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Visualización del historial"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Affichage de l'historique"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"historySummaryFormat" : {
|
"historySummaryFormat" : {
|
||||||
"comment" : "Format string used to create a summary of the user's game history, including the total number of rounds played, as well as the number of rounds won by the player, the banker, and as ties.",
|
"comment" : "Format string used to create a summary of the user's game history, including the total number of rounds played, as well as the number of rounds won by the player, the banker, and as ties.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -2899,6 +2977,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Practice Free" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Privacy Policy" : {
|
"Privacy Policy" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -2922,6 +3003,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Red Circle (B): Banker won the hand" : {
|
||||||
|
"comment" : "Explains the red circle icon in the history.",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Red Circle (B): Banker won the hand"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Círculo Rojo (B): El banquero ganó la mano"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Cercle Rouge (B) : Le banquier a gagné la main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Reset Game" : {
|
"Reset Game" : {
|
||||||
"comment" : "A button label that resets the game balance and reshuffles the deck.",
|
"comment" : "A button label that resets the game balance and reshuffles the deck.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -2994,6 +3098,12 @@
|
|||||||
"Result distribution" : {
|
"Result distribution" : {
|
||||||
"comment" : "A label describing the view that shows the distribution of betting results.",
|
"comment" : "A label describing the view that shows the distribution of betting results.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Results appear here, then in the road maps below" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Road maps show game history and trends" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Roulette" : {
|
"Roulette" : {
|
||||||
"comment" : "The name of a roulette game.",
|
"comment" : "The name of a roulette game.",
|
||||||
@ -3063,6 +3173,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Select a chip and tap a bet zone" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Set a budget and stick to it." : {
|
"Set a budget and stick to it." : {
|
||||||
"comment" : "Tip for players to set a budget and stick to it when playing baccarat.",
|
"comment" : "Tip for players to set a budget and stick to it when playing baccarat.",
|
||||||
@ -3158,6 +3271,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Show Welcome Again" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Side bet on Player or Banker winning by a margin." : {
|
"Side bet on Player or Banker winning by a margin." : {
|
||||||
"comment" : "Title for a side bet where the player bets on which hand wins by a margin (e.g., Banker by 9 points).",
|
"comment" : "Title for a side bet where the player bets on which hand wins by a margin (e.g., Banker by 9 points).",
|
||||||
@ -3272,6 +3388,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Start with $1,000 and play risk-free" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"STARTING BALANCE" : {
|
"STARTING BALANCE" : {
|
||||||
"comment" : "Section header for starting balance settings.",
|
"comment" : "Section header for starting balance settings.",
|
||||||
@ -3439,6 +3558,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Tap Deal to start the round" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"The hand closest to 9 wins" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"The hand closest to 9 wins." : {
|
"The hand closest to 9 wins." : {
|
||||||
"comment" : "Explanation of how the hand closest to 9 wins in baccarat.",
|
"comment" : "Explanation of how the hand closest to 9 wins in baccarat.",
|
||||||
@ -3463,6 +3588,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"The History shows all previous round results at a glance." : {
|
||||||
|
"comment" : "Explains the purpose of the history display.",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "The History shows all previous round results at a glance."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "El Historial muestra todos los resultados de rondas anteriores de un vistazo."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "L'historique affiche tous les résultats des tours précédents en un coup d'œil."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"There's no skill involved — just enjoy the game!" : {
|
"There's no skill involved — just enjoy the game!" : {
|
||||||
"comment" : "Tip for players on how to play baccarat without needing any skill.",
|
"comment" : "Tip for players on how to play baccarat without needing any skill.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -3694,6 +3842,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Track Patterns" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Two Naturals of the same value result in a Tie." : {
|
"Two Naturals of the same value result in a Tie." : {
|
||||||
"comment" : "Text describing the outcome when two players both have a Natural (a total of 8 or 9 with two cards).",
|
"comment" : "Text describing the outcome when two players both have a Natural (a total of 8 or 9 with two cards).",
|
||||||
@ -3741,6 +3892,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Use History to spot patterns and trends in the shoe." : {
|
||||||
|
"comment" : "Advises the player on how to use the history feature.",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Use History to spot patterns and trends in the shoe."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Usa el Historial para detectar patrones y tendencias en el zapato."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Utilisez l'historique pour repérer les modèles et les tendances dans le sabot."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Vibration for actions and results" : {
|
"Vibration for actions and results" : {
|
||||||
"comment" : "Subtitle describing haptic feedback toggle.",
|
"comment" : "Subtitle describing haptic feedback toggle.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -3948,6 +4122,52 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Yellow Dot (bottom-left): A pair occurred in that hand" : {
|
||||||
|
"comment" : "Explains the yellow dot marker in the history.",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Yellow Dot (bottom-left): A pair occurred in that hand"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Punto Amarillo (abajo-izquierda): Hubo un par en esa mano"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Point Jaune (en bas à gauche) : Une paire s'est produite dans cette main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Yellow Star (top-right): Natural 8 or 9 win" : {
|
||||||
|
"comment" : "Explains the yellow star marker in the history.",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Yellow Star (top-right): Natural 8 or 9 win"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Estrella Amarilla (arriba-derecha): Victoria natural con 8 o 9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Étoile Jaune (en haut à droite) : Victoire naturelle avec 8 ou 9"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"You've run out of chips!" : {
|
"You've run out of chips!" : {
|
||||||
"comment" : "A message displayed when a player runs out of money in the game over screen.",
|
"comment" : "A message displayed when a player runs out of money in the game over screen.",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import CasinoKit
|
|||||||
enum Design {
|
enum Design {
|
||||||
|
|
||||||
/// Set to true to show layout debug borders on views
|
/// Set to true to show layout debug borders on views
|
||||||
static let showDebugBorders = true
|
static let showDebugBorders = false
|
||||||
|
|
||||||
// MARK: - Shared Constants (from CasinoKit)
|
// MARK: - Shared Constants (from CasinoKit)
|
||||||
|
|
||||||
@ -59,10 +59,10 @@ enum Design {
|
|||||||
// MARK: - Card Deal Animation
|
// MARK: - Card Deal Animation
|
||||||
|
|
||||||
enum DealAnimation {
|
enum DealAnimation {
|
||||||
/// Horizontal offset for card deal (from upper-center, simulating dealer)
|
/// Horizontal offset for card deal (shoe position in upper-right area)
|
||||||
static let offsetX: CGFloat = 0
|
static let offsetX: CGFloat = 150
|
||||||
/// Vertical offset for card deal (from above the table)
|
/// Vertical offset for card deal (from top of screen where shoe is positioned)
|
||||||
static let offsetY: CGFloat = -250
|
static let offsetY: CGFloat = -300
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,12 @@ struct GameTableView: View {
|
|||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
@State private var showRules = false
|
@State private var showRules = false
|
||||||
@State private var showStats = false
|
@State private var showStats = false
|
||||||
|
@State private var showWelcome = false
|
||||||
|
|
||||||
|
// MARK: - Onboarding State
|
||||||
|
|
||||||
|
/// Tooltip manager for contextual hints
|
||||||
|
@State private var tooltipManager: TooltipManager?
|
||||||
|
|
||||||
/// Screen size for card sizing (measured from TableBackgroundView)
|
/// Screen size for card sizing (measured from TableBackgroundView)
|
||||||
@State private var screenSize: CGSize = CGSize(width: 375, height: 667)
|
@State private var screenSize: CGSize = CGSize(width: 375, height: 667)
|
||||||
@ -103,6 +109,10 @@ struct GameTableView: View {
|
|||||||
if gameState == nil {
|
if gameState == nil {
|
||||||
gameState = GameState(settings: settings)
|
gameState = GameState(settings: settings)
|
||||||
}
|
}
|
||||||
|
if tooltipManager == nil {
|
||||||
|
tooltipManager = TooltipManager(onboarding: state.onboarding)
|
||||||
|
}
|
||||||
|
checkForWelcomeSheet()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showSettings) {
|
.sheet(isPresented: $showSettings) {
|
||||||
if let state = gameState {
|
if let state = gameState {
|
||||||
@ -111,12 +121,112 @@ struct GameTableView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: showSettings) { wasShowing, isShowing in
|
||||||
|
// When settings sheet dismisses, check if we should show welcome
|
||||||
|
if wasShowing && !isShowing {
|
||||||
|
checkForWelcomeSheet()
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showRules) {
|
.sheet(isPresented: $showRules) {
|
||||||
RulesHelpView()
|
RulesHelpView()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showStats) {
|
.sheet(isPresented: $showStats) {
|
||||||
StatisticsSheetView(results: state.roundHistory)
|
StatisticsSheetView(results: state.roundHistory)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showWelcome) {
|
||||||
|
WelcomeSheet(
|
||||||
|
gameName: "Baccarat",
|
||||||
|
features: [
|
||||||
|
WelcomeFeature(
|
||||||
|
icon: "hand.raised.fill",
|
||||||
|
title: String(localized: "Bet on Player, Banker, or Tie"),
|
||||||
|
description: String(localized: "The hand closest to 9 wins")
|
||||||
|
),
|
||||||
|
WelcomeFeature(
|
||||||
|
icon: "chart.line.uptrend.xyaxis",
|
||||||
|
title: String(localized: "Track Patterns"),
|
||||||
|
description: String(localized: "Road maps show game history and trends")
|
||||||
|
),
|
||||||
|
WelcomeFeature(
|
||||||
|
icon: "dollarsign.circle",
|
||||||
|
title: String(localized: "Practice Free"),
|
||||||
|
description: String(localized: "Start with $1,000 and play risk-free")
|
||||||
|
),
|
||||||
|
WelcomeFeature(
|
||||||
|
icon: "gearshape.fill",
|
||||||
|
title: String(localized: "Customize Settings"),
|
||||||
|
description: String(localized: "Change table limits and display options")
|
||||||
|
)
|
||||||
|
],
|
||||||
|
onStartTutorial: {
|
||||||
|
showWelcome = false
|
||||||
|
state.onboarding.completeWelcome()
|
||||||
|
checkOnboardingHints()
|
||||||
|
},
|
||||||
|
onStartPlaying: {
|
||||||
|
showWelcome = false
|
||||||
|
state.onboarding.completeWelcome()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onChange(of: state.totalBetAmount) { _, newTotal in
|
||||||
|
if newTotal > 0, state.onboarding.shouldShowHint("dealButton") {
|
||||||
|
showDealHintWithDelay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: state.currentPhase) { oldPhase, newPhase in
|
||||||
|
if newPhase == .showingResult, oldPhase != newPhase {
|
||||||
|
if state.onboarding.shouldShowHint("firstResult") {
|
||||||
|
showResultHintWithDelay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic tooltip display
|
||||||
|
.dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: state.onboarding))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Onboarding Helpers
|
||||||
|
|
||||||
|
private func checkForWelcomeSheet() {
|
||||||
|
if !state.onboarding.hasCompletedWelcome {
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
showWelcome = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkOnboardingHints() {
|
||||||
|
if state.onboarding.shouldShowHint("bettingZone") {
|
||||||
|
tooltipManager?.show(
|
||||||
|
key: "bettingZone",
|
||||||
|
message: String(localized: "Select a chip and tap a bet zone"),
|
||||||
|
icon: "hand.tap.fill",
|
||||||
|
position: .bottom,
|
||||||
|
delay: 1.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showDealHintWithDelay() {
|
||||||
|
tooltipManager?.show(
|
||||||
|
key: "dealButton",
|
||||||
|
message: String(localized: "Tap Deal to start the round"),
|
||||||
|
icon: "play.fill",
|
||||||
|
position: .bottom,
|
||||||
|
delay: 0.5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showResultHintWithDelay() {
|
||||||
|
tooltipManager?.show(
|
||||||
|
key: "firstResult",
|
||||||
|
message: String(localized: "Results appear here, then in the road maps below"),
|
||||||
|
icon: "chart.bar.fill",
|
||||||
|
position: .bottom,
|
||||||
|
delay: 2.0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Views
|
// MARK: - Private Views
|
||||||
|
|||||||
@ -103,6 +103,19 @@ struct RulesHelpView: View {
|
|||||||
String(localized: "Independent of the main game result.")
|
String(localized: "Independent of the main game result.")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
RulePage(
|
||||||
|
title: String(localized: "History Display"),
|
||||||
|
icon: "clock.fill",
|
||||||
|
content: [
|
||||||
|
String(localized: "The History shows all previous round results at a glance."),
|
||||||
|
String(localized: "Blue Circle (P): Player won the hand"),
|
||||||
|
String(localized: "Red Circle (B): Banker won the hand"),
|
||||||
|
String(localized: "Green Circle (T): Tie between Player and Banker"),
|
||||||
|
String(localized: "Yellow Dot (bottom-left): A pair occurred in that hand"),
|
||||||
|
String(localized: "Yellow Star (top-right): Natural 8 or 9 win"),
|
||||||
|
String(localized: "Use History to spot patterns and trends in the shoe.")
|
||||||
|
]
|
||||||
|
),
|
||||||
RulePage(
|
RulePage(
|
||||||
title: String(localized: "Strategy Tips"),
|
title: String(localized: "Strategy Tips"),
|
||||||
icon: "lightbulb.fill",
|
icon: "lightbulb.fill",
|
||||||
@ -195,7 +208,7 @@ struct RulePageView: View {
|
|||||||
// Content
|
// Content
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
ForEach(page.content.indices, id: \.self) { index in
|
ForEach(page.content.indices, id: \.self) { index in
|
||||||
HStack(alignment: .top, spacing: Design.Spacing.medium) {
|
HStack(alignment: .firstTextBaseline, spacing: Design.Spacing.medium) {
|
||||||
Text("•")
|
Text("•")
|
||||||
.foregroundStyle(Color.Sheet.accent)
|
.foregroundStyle(Color.Sheet.accent)
|
||||||
|
|
||||||
|
|||||||
@ -329,7 +329,28 @@ struct SettingsView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, Design.Spacing.small)
|
.padding(.top, Design.Spacing.small)
|
||||||
|
|
||||||
// 9. App Version
|
// 10. Show Welcome Again (Reset Onboarding)
|
||||||
|
Button {
|
||||||
|
gameState.onboarding.reset()
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "hand.wave")
|
||||||
|
Text(String(localized: "Show Welcome Again"))
|
||||||
|
}
|
||||||
|
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
|
.fill(Color.white.opacity(Design.Opacity.subtle))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, Design.Spacing.xSmall)
|
||||||
|
|
||||||
|
// 11. App Version
|
||||||
Text(appVersionString)
|
Text(appVersionString)
|
||||||
.font(.system(size: Design.BaseFontSize.callout))
|
.font(.system(size: Design.BaseFontSize.callout))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||||
|
|||||||
@ -8,6 +8,12 @@ A feature-rich Baccarat (Punto Banco) app for iOS built with SwiftUI. Experience
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### 🎓 First-Time User Experience
|
||||||
|
- **Welcome Sheet** — Interactive introduction on first launch
|
||||||
|
- **Tutorial Mode** — Optional guided walkthrough of your first round
|
||||||
|
- **Contextual Hints** — Tips appear at the right moment during gameplay
|
||||||
|
- **Never Intrusive** — All onboarding is skippable and shown only once
|
||||||
|
|
||||||
### 🎰 Authentic Punto Banco Gameplay
|
### 🎰 Authentic Punto Banco Gameplay
|
||||||
- Complete Baccarat rules with automatic third card logic
|
- Complete Baccarat rules with automatic third card logic
|
||||||
- Natural detection (8 or 9 on initial deal)
|
- Natural detection (8 or 9 on initial deal)
|
||||||
|
|||||||
@ -126,6 +126,9 @@ final class GameState {
|
|||||||
/// Game settings.
|
/// Game settings.
|
||||||
let settings: GameSettings
|
let settings: GameSettings
|
||||||
|
|
||||||
|
/// Onboarding state for first-time users.
|
||||||
|
let onboarding: OnboardingState
|
||||||
|
|
||||||
/// Sound manager.
|
/// Sound manager.
|
||||||
private let sound = SoundManager.shared
|
private let sound = SoundManager.shared
|
||||||
|
|
||||||
@ -323,6 +326,7 @@ final class GameState {
|
|||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.balance = settings.startingBalance
|
self.balance = settings.startingBalance
|
||||||
self.engine = BlackjackEngine(settings: settings)
|
self.engine = BlackjackEngine(settings: settings)
|
||||||
|
self.onboarding = OnboardingState(gameIdentifier: "blackjack")
|
||||||
self.persistence = CloudSyncManager<BlackjackGameData>()
|
self.persistence = CloudSyncManager<BlackjackGameData>()
|
||||||
syncSoundSettings()
|
syncSoundSettings()
|
||||||
loadSavedGame()
|
loadSavedGame()
|
||||||
|
|||||||
@ -114,7 +114,7 @@ final class GameSettings {
|
|||||||
// MARK: - Betting Limits
|
// MARK: - Betting Limits
|
||||||
|
|
||||||
/// The table limits preset.
|
/// The table limits preset.
|
||||||
var tableLimits: TableLimits = .low { didSet { save() } }
|
var tableLimits: TableLimits = .casual { didSet { save() } }
|
||||||
|
|
||||||
/// Minimum bet amount.
|
/// Minimum bet amount.
|
||||||
var minBet: Int { tableLimits.minBet }
|
var minBet: Int { tableLimits.minBet }
|
||||||
@ -125,7 +125,7 @@ final class GameSettings {
|
|||||||
// MARK: - Starting Balance
|
// MARK: - Starting Balance
|
||||||
|
|
||||||
/// The starting balance for new games.
|
/// The starting balance for new games.
|
||||||
var startingBalance: Int = 10_000 { didSet { save() } }
|
var startingBalance: Int = 1_000 { didSet { save() } }
|
||||||
|
|
||||||
// MARK: - Animation Settings
|
// MARK: - Animation Settings
|
||||||
|
|
||||||
@ -286,12 +286,12 @@ final class GameSettings {
|
|||||||
func resetToDefaults() {
|
func resetToDefaults() {
|
||||||
gameStyle = .vegas
|
gameStyle = .vegas
|
||||||
deckCount = .six
|
deckCount = .six
|
||||||
tableLimits = .low
|
tableLimits = .casual
|
||||||
startingBalance = 10_000
|
startingBalance = 1_000
|
||||||
dealerHitsSoft17 = false
|
dealerHitsSoft17 = false
|
||||||
doubleAfterSplit = true
|
doubleAfterSplit = true
|
||||||
resplitAces = false
|
resplitAces = false
|
||||||
lateSurrender = true
|
lateSurrender = false
|
||||||
noHoleCard = false
|
noHoleCard = false
|
||||||
blackjackPayout = 1.5
|
blackjackPayout = 1.5
|
||||||
insuranceAllowed = true
|
insuranceAllowed = true
|
||||||
|
|||||||
@ -1169,6 +1169,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Beat the Dealer" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Beat the dealer by getting a hand value closer to 21 without going over." : {
|
"Beat the dealer by getting a hand value closer to 21 without going over." : {
|
||||||
"comment" : "Text for the objective of the game.",
|
"comment" : "Text for the objective of the game.",
|
||||||
@ -1658,6 +1661,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Built-in hints show optimal plays based on basic strategy" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"BUST" : {
|
"BUST" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1856,6 +1862,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Change table limits, rules, and side bets in settings" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Chips, cards, and results" : {
|
"Chips, cards, and results" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1878,6 +1887,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Choose your action based on the hint above" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Clear" : {
|
"Clear" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -2196,6 +2208,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Customize Rules" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"DATA" : {
|
"DATA" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -3392,6 +3407,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Get closer to 21 than the dealer without going over" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"H17 rule, increases house edge" : {
|
"H17 rule, increases house edge" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -4097,6 +4115,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Learn Strategy" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"LEGAL" : {
|
"LEGAL" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -5100,6 +5121,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Practice Free" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Privacy Policy" : {
|
"Privacy Policy" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -5529,6 +5553,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Select a chip and tap the bet area" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"SESSION SUMMARY" : {
|
"SESSION SUMMARY" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -5737,6 +5764,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Show Welcome Again" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Side Bets" : {
|
"Side Bets" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -6167,6 +6197,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Start with $1,000 and play risk-free" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"STARTING BALANCE" : {
|
"STARTING BALANCE" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -6573,6 +6606,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Tap Deal to start the round" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"TAP TO BET" : {
|
"TAP TO BET" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@ -18,6 +18,12 @@ struct GameTableView: View {
|
|||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
@State private var showRules = false
|
@State private var showRules = false
|
||||||
@State private var showStats = false
|
@State private var showStats = false
|
||||||
|
@State private var showWelcome = false
|
||||||
|
|
||||||
|
// MARK: - Onboarding State
|
||||||
|
|
||||||
|
/// Tooltip manager for contextual hints
|
||||||
|
@State private var tooltipManager: TooltipManager?
|
||||||
|
|
||||||
/// Screen size for card sizing (measured from TableBackgroundView)
|
/// Screen size for card sizing (measured from TableBackgroundView)
|
||||||
@State private var screenSize: CGSize = CGSize(width: 375, height: 667)
|
@State private var screenSize: CGSize = CGSize(width: 375, height: 667)
|
||||||
@ -55,16 +61,74 @@ struct GameTableView: View {
|
|||||||
if gameState == nil {
|
if gameState == nil {
|
||||||
gameState = GameState(settings: settings)
|
gameState = GameState(settings: settings)
|
||||||
}
|
}
|
||||||
|
if tooltipManager == nil {
|
||||||
|
tooltipManager = TooltipManager(onboarding: state.onboarding)
|
||||||
|
}
|
||||||
|
checkForWelcomeSheet()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showSettings) {
|
.sheet(isPresented: $showSettings) {
|
||||||
SettingsView(settings: settings, gameState: gameState)
|
SettingsView(settings: settings, gameState: gameState)
|
||||||
}
|
}
|
||||||
|
.onChange(of: showSettings) { wasShowing, isShowing in
|
||||||
|
// When settings sheet dismisses, check if we should show welcome
|
||||||
|
if wasShowing && !isShowing {
|
||||||
|
checkForWelcomeSheet()
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showRules) {
|
.sheet(isPresented: $showRules) {
|
||||||
RulesHelpView()
|
RulesHelpView()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showStats) {
|
.sheet(isPresented: $showStats) {
|
||||||
StatisticsSheetView(state: state)
|
StatisticsSheetView(state: state)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showWelcome) {
|
||||||
|
WelcomeSheet(
|
||||||
|
gameName: "Blackjack",
|
||||||
|
features: [
|
||||||
|
WelcomeFeature(
|
||||||
|
icon: "target",
|
||||||
|
title: String(localized: "Beat the Dealer"),
|
||||||
|
description: String(localized: "Get closer to 21 than the dealer without going over")
|
||||||
|
),
|
||||||
|
WelcomeFeature(
|
||||||
|
icon: "lightbulb.fill",
|
||||||
|
title: String(localized: "Learn Strategy"),
|
||||||
|
description: String(localized: "Built-in hints show optimal plays based on basic strategy")
|
||||||
|
),
|
||||||
|
WelcomeFeature(
|
||||||
|
icon: "dollarsign.circle",
|
||||||
|
title: String(localized: "Practice Free"),
|
||||||
|
description: String(localized: "Start with $1,000 and play risk-free")
|
||||||
|
),
|
||||||
|
WelcomeFeature(
|
||||||
|
icon: "gearshape.fill",
|
||||||
|
title: String(localized: "Customize Rules"),
|
||||||
|
description: String(localized: "Change table limits, rules, and side bets in settings")
|
||||||
|
)
|
||||||
|
],
|
||||||
|
onStartTutorial: {
|
||||||
|
showWelcome = false
|
||||||
|
state.onboarding.completeWelcome()
|
||||||
|
checkOnboardingHints()
|
||||||
|
},
|
||||||
|
onStartPlaying: {
|
||||||
|
showWelcome = false
|
||||||
|
state.onboarding.completeWelcome()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onChange(of: state.currentBet) { _, newBet in
|
||||||
|
if newBet > 0, state.onboarding.shouldShowHint("dealButton") {
|
||||||
|
showDealHintWithDelay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: state.currentPhase) { oldPhase, newPhase in
|
||||||
|
if case .playerTurn = newPhase, oldPhase != newPhase {
|
||||||
|
if state.onboarding.shouldShowHint("playerActions") {
|
||||||
|
showActionsHintWithDelay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use global debug flag from Design constants
|
// Use global debug flag from Design constants
|
||||||
@ -248,6 +312,54 @@ struct GameTableView: View {
|
|||||||
.onChange(of: state.balance) { oldBalance, newBalance in
|
.onChange(of: state.balance) { oldBalance, newBalance in
|
||||||
Design.debugLog("💰 Balance: \(oldBalance) → \(newBalance)")
|
Design.debugLog("💰 Balance: \(oldBalance) → \(newBalance)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dynamic tooltip display
|
||||||
|
.dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: state.onboarding))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Onboarding Helpers
|
||||||
|
|
||||||
|
private func checkForWelcomeSheet() {
|
||||||
|
if !state.onboarding.hasCompletedWelcome {
|
||||||
|
// Delay slightly so view has time to layout
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
showWelcome = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkOnboardingHints() {
|
||||||
|
// Show betting hint if not yet shown
|
||||||
|
if state.onboarding.shouldShowHint("bettingZone") {
|
||||||
|
tooltipManager?.show(
|
||||||
|
key: "bettingZone",
|
||||||
|
message: String(localized: "Select a chip and tap the bet area"),
|
||||||
|
icon: "hand.tap.fill",
|
||||||
|
position: .bottom,
|
||||||
|
delay: 1.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showDealHintWithDelay() {
|
||||||
|
tooltipManager?.show(
|
||||||
|
key: "dealButton",
|
||||||
|
message: String(localized: "Tap Deal to start the round"),
|
||||||
|
icon: "play.fill",
|
||||||
|
position: .bottom,
|
||||||
|
delay: 0.5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showActionsHintWithDelay() {
|
||||||
|
tooltipManager?.show(
|
||||||
|
key: "playerActions",
|
||||||
|
message: String(localized: "Choose your action based on the hint above"),
|
||||||
|
icon: "hand.point.up.left.fill",
|
||||||
|
position: .bottom,
|
||||||
|
delay: 1.0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -303,7 +303,7 @@ struct RulePageView: View {
|
|||||||
// Content
|
// Content
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
ForEach(page.content.indices, id: \.self) { index in
|
ForEach(page.content.indices, id: \.self) { index in
|
||||||
HStack(alignment: .top, spacing: Design.Spacing.medium) {
|
HStack(alignment: .firstTextBaseline, spacing: Design.Spacing.medium) {
|
||||||
Text("•")
|
Text("•")
|
||||||
.foregroundStyle(Color.Sheet.accent)
|
.foregroundStyle(Color.Sheet.accent)
|
||||||
|
|
||||||
|
|||||||
@ -385,7 +385,30 @@ struct SettingsView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, Design.Spacing.small)
|
.padding(.top, Design.Spacing.small)
|
||||||
|
|
||||||
// 12. Version info
|
// 12. Show Welcome Again (Reset Onboarding)
|
||||||
|
if let state = gameState {
|
||||||
|
Button {
|
||||||
|
state.onboarding.reset()
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "hand.wave")
|
||||||
|
Text(String(localized: "Show Welcome Again"))
|
||||||
|
}
|
||||||
|
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
|
.fill(Color.white.opacity(Design.Opacity.subtle))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, Design.Spacing.xSmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Version info
|
||||||
Text(appVersionString)
|
Text(appVersionString)
|
||||||
.font(.system(size: Design.BaseFontSize.small))
|
.font(.system(size: Design.BaseFontSize.small))
|
||||||
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
.foregroundStyle(.white.opacity(Design.Opacity.light))
|
||||||
|
|||||||
@ -8,6 +8,12 @@ A feature-rich Blackjack app for iOS built with SwiftUI. Master basic strategy,
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### 🎓 First-Time User Experience
|
||||||
|
- **Welcome Sheet** — Interactive introduction on first launch
|
||||||
|
- **Tutorial Mode** — Optional guided walkthrough of your first hand
|
||||||
|
- **Contextual Hints** — Tips appear at the right moment during gameplay
|
||||||
|
- **Never Intrusive** — All onboarding is skippable and shown only once
|
||||||
|
|
||||||
### 🎰 Authentic Casino Gameplay
|
### 🎰 Authentic Casino Gameplay
|
||||||
- Full Blackjack gameplay with all standard actions: Hit, Stand, Double Down, Split, Surrender
|
- Full Blackjack gameplay with all standard actions: Hit, Stand, Double Down, Split, Surrender
|
||||||
- Insurance betting when dealer shows an Ace
|
- Insurance betting when dealer shows an Ace
|
||||||
|
|||||||
@ -110,6 +110,125 @@ SheetSection(title: "SECTION TITLE", icon: "star.fill") {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 🎓 Onboarding & Tutorials
|
||||||
|
|
||||||
|
**WelcomeSheet** - First-launch welcome screen with features list.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
WelcomeSheet(
|
||||||
|
gameName: "Blackjack",
|
||||||
|
features: [
|
||||||
|
WelcomeFeature(
|
||||||
|
icon: "target",
|
||||||
|
title: "Beat the Dealer",
|
||||||
|
description: "Get closer to 21 than the dealer without going over"
|
||||||
|
),
|
||||||
|
WelcomeFeature(
|
||||||
|
icon: "lightbulb.fill",
|
||||||
|
title: "Learn Strategy",
|
||||||
|
description: "Built-in hints show optimal plays"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
onStartTutorial: {
|
||||||
|
// Enable tutorial mode
|
||||||
|
gameState.onboarding.startTutorialMode()
|
||||||
|
},
|
||||||
|
onStartPlaying: {
|
||||||
|
// Skip to game
|
||||||
|
gameState.onboarding.completeWelcome()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**ContextualTooltip** - Show hints at the right moment.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@State private var showBettingHint = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
BettingZone(/* ... */)
|
||||||
|
.contextualTooltip(
|
||||||
|
"Tap chips, then tap here to bet",
|
||||||
|
icon: "hand.tap.fill",
|
||||||
|
position: .bottom,
|
||||||
|
isShowing: $showBettingHint,
|
||||||
|
onDismiss: {
|
||||||
|
gameState.onboarding.markHintShown("bettingZone")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**OnboardingState** - Track which hints have been shown.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let onboarding = OnboardingState(gameIdentifier: "blackjack")
|
||||||
|
|
||||||
|
// Check if a hint should be shown
|
||||||
|
if onboarding.shouldShowHint("bettingZone") {
|
||||||
|
showBettingHint = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark hint as shown (persisted automatically)
|
||||||
|
onboarding.markHintShown("bettingZone")
|
||||||
|
|
||||||
|
// Check if user has seen welcome
|
||||||
|
if !onboarding.hasCompletedWelcome {
|
||||||
|
showWelcome = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable tutorial mode (shows all hints again)
|
||||||
|
onboarding.startTutorialMode()
|
||||||
|
|
||||||
|
// Reset onboarding (for testing)
|
||||||
|
onboarding.reset()
|
||||||
|
```
|
||||||
|
|
||||||
|
**TooltipManager** - Generic, scalable tooltip management (recommended).
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@State private var tooltipManager: TooltipManager?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GameView()
|
||||||
|
.onAppear {
|
||||||
|
tooltipManager = TooltipManager(onboarding: gameState.onboarding)
|
||||||
|
}
|
||||||
|
.dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: gameState.onboarding))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a tooltip with automatic dismiss tracking
|
||||||
|
private func showBettingHint() {
|
||||||
|
tooltipManager?.show(
|
||||||
|
key: "bettingZone",
|
||||||
|
message: "Select a chip and tap here to bet",
|
||||||
|
icon: "hand.tap.fill",
|
||||||
|
position: .bottom,
|
||||||
|
delay: 1.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss current tooltip manually
|
||||||
|
tooltipManager?.dismiss()
|
||||||
|
```
|
||||||
|
|
||||||
|
The `TooltipManager` automatically:
|
||||||
|
- Ensures only one tooltip shows at a time
|
||||||
|
- Marks tooltips as shown when dismissed or replaced
|
||||||
|
- Respects `OnboardingState.shouldShowHint()` checks
|
||||||
|
- Handles delayed presentation with cancellation if needed
|
||||||
|
|
||||||
|
**PulsingModifier** - Draw attention to interactive elements.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
Button("Deal", action: deal)
|
||||||
|
.pulsing(
|
||||||
|
isActive: shouldHighlight,
|
||||||
|
color: .white,
|
||||||
|
scale: 1.3
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
### 🎲 Game Table Components
|
### 🎲 Game Table Components
|
||||||
|
|
||||||
**TableBackgroundView** - Casino felt background with pattern.
|
**TableBackgroundView** - Casino felt background with pattern.
|
||||||
@ -569,7 +688,9 @@ CasinoKit/
|
|||||||
│ │ ├── Card.swift
|
│ │ ├── Card.swift
|
||||||
│ │ ├── Deck.swift
|
│ │ ├── Deck.swift
|
||||||
│ │ ├── ChipDenomination.swift
|
│ │ ├── ChipDenomination.swift
|
||||||
│ │ └── TableLimits.swift # Betting limit presets
|
│ │ ├── TableLimits.swift # Betting limit presets
|
||||||
|
│ │ ├── OnboardingState.swift # Onboarding tracking
|
||||||
|
│ │ └── TooltipManager.swift # Tooltip management
|
||||||
│ ├── Views/
|
│ ├── Views/
|
||||||
│ │ ├── Cards/
|
│ │ ├── Cards/
|
||||||
│ │ │ ├── CardView.swift
|
│ │ │ ├── CardView.swift
|
||||||
@ -581,6 +702,10 @@ CasinoKit/
|
|||||||
│ │ │ └── ChipOnTableView.swift
|
│ │ │ └── ChipOnTableView.swift
|
||||||
│ │ ├── Sheets/
|
│ │ ├── Sheets/
|
||||||
│ │ │ └── SheetContainerView.swift
|
│ │ │ └── SheetContainerView.swift
|
||||||
|
│ │ ├── Onboarding/
|
||||||
|
│ │ │ ├── WelcomeSheet.swift # First-launch welcome
|
||||||
|
│ │ │ ├── ContextualTooltip.swift # In-game hints
|
||||||
|
│ │ │ └── PulsingModifier.swift # Attention-grabbing pulse
|
||||||
│ │ ├── Branding/
|
│ │ ├── Branding/
|
||||||
│ │ │ ├── AppIconView.swift
|
│ │ │ ├── AppIconView.swift
|
||||||
│ │ │ ├── LaunchScreenView.swift
|
│ │ │ ├── LaunchScreenView.swift
|
||||||
@ -663,7 +788,8 @@ private var fontSize: CGFloat = CasinoDesign.BaseFontSize.body
|
|||||||
|
|
||||||
## Apps Using CasinoKit
|
## Apps Using CasinoKit
|
||||||
|
|
||||||
- **Baccarat** - The classic casino card game
|
- **Blackjack** - Classic 21 with basic strategy hints and card counting
|
||||||
|
- **Baccarat** - The classic casino card game with road maps
|
||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,8 @@
|
|||||||
// - Deck
|
// - Deck
|
||||||
// - ChipDenomination
|
// - ChipDenomination
|
||||||
// - TableLimits
|
// - TableLimits
|
||||||
|
// - OnboardingState
|
||||||
|
// - TooltipManager, TooltipConfig
|
||||||
|
|
||||||
// MARK: - Views
|
// MARK: - Views
|
||||||
// - CardView, CardFrontView, CardBackView, CardPlaceholderView
|
// - CardView, CardFrontView, CardBackView, CardPlaceholderView
|
||||||
@ -21,6 +23,9 @@
|
|||||||
// - ChipSelectorView
|
// - ChipSelectorView
|
||||||
// - ChipStackView, ChipOnTableView
|
// - ChipStackView, ChipOnTableView
|
||||||
// - SheetContainerView, SheetSection
|
// - SheetContainerView, SheetSection
|
||||||
|
// - WelcomeSheet, WelcomeFeature
|
||||||
|
// - ContextualTooltip, ContextualTooltipModifier
|
||||||
|
// - PulsingModifier
|
||||||
|
|
||||||
// MARK: - Effects
|
// MARK: - Effects
|
||||||
// - ConfettiView, ConfettiPiece
|
// - ConfettiView, ConfettiPiece
|
||||||
|
|||||||
96
CasinoKit/Sources/CasinoKit/Models/OnboardingState.swift
Normal file
96
CasinoKit/Sources/CasinoKit/Models/OnboardingState.swift
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
//
|
||||||
|
// OnboardingState.swift
|
||||||
|
// CasinoKit
|
||||||
|
//
|
||||||
|
// Tracks first-time user onboarding progress and contextual hints.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Observable state for managing user onboarding and progressive feature discovery.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
public final class OnboardingState {
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
/// Whether the user has launched the app before.
|
||||||
|
public var hasLaunchedBefore: Bool = false
|
||||||
|
|
||||||
|
/// Whether the user has completed the welcome sheet.
|
||||||
|
public var hasCompletedWelcome: Bool = false
|
||||||
|
|
||||||
|
/// Whether the user is in tutorial mode (shows all contextual hints).
|
||||||
|
public var isTutorialMode: Bool = false
|
||||||
|
|
||||||
|
/// Set of hint keys that have been shown to the user.
|
||||||
|
public var hintsShown: Set<String> = []
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
private let persistenceKey: String
|
||||||
|
|
||||||
|
public init(gameIdentifier: String) {
|
||||||
|
self.persistenceKey = "onboarding.\(gameIdentifier)"
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hint Management
|
||||||
|
|
||||||
|
/// Marks a hint as shown and persists the state.
|
||||||
|
public func markHintShown(_ key: String) {
|
||||||
|
hintsShown.insert(key)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether a specific hint should be shown.
|
||||||
|
public func shouldShowHint(_ key: String) -> Bool {
|
||||||
|
isTutorialMode || !hintsShown.contains(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the welcome sheet as completed.
|
||||||
|
public func completeWelcome() {
|
||||||
|
hasLaunchedBefore = true
|
||||||
|
hasCompletedWelcome = true
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables tutorial mode (shows all hints again).
|
||||||
|
public func startTutorialMode() {
|
||||||
|
isTutorialMode = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disables tutorial mode.
|
||||||
|
public func endTutorialMode() {
|
||||||
|
isTutorialMode = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets all onboarding state (for testing or user-requested reset).
|
||||||
|
public func reset() {
|
||||||
|
hasLaunchedBefore = false
|
||||||
|
hasCompletedWelcome = false
|
||||||
|
isTutorialMode = false
|
||||||
|
hintsShown.removeAll()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
hasLaunchedBefore = defaults.bool(forKey: "\(persistenceKey).hasLaunched")
|
||||||
|
hasCompletedWelcome = defaults.bool(forKey: "\(persistenceKey).hasCompletedWelcome")
|
||||||
|
|
||||||
|
if let hintsData = defaults.array(forKey: "\(persistenceKey).hintsShown") as? [String] {
|
||||||
|
hintsShown = Set(hintsData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
defaults.set(hasLaunchedBefore, forKey: "\(persistenceKey).hasLaunched")
|
||||||
|
defaults.set(hasCompletedWelcome, forKey: "\(persistenceKey).hasCompletedWelcome")
|
||||||
|
defaults.set(Array(hintsShown), forKey: "\(persistenceKey).hintsShown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
114
CasinoKit/Sources/CasinoKit/Models/TooltipManager.swift
Normal file
114
CasinoKit/Sources/CasinoKit/Models/TooltipManager.swift
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
//
|
||||||
|
// TooltipManager.swift
|
||||||
|
// CasinoKit
|
||||||
|
//
|
||||||
|
// Manages contextual tooltips for onboarding.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Configuration for a contextual tooltip
|
||||||
|
public struct TooltipConfig: Equatable {
|
||||||
|
public let key: String
|
||||||
|
public let message: String
|
||||||
|
public let icon: String
|
||||||
|
public let position: ContextualTooltip.TooltipPosition
|
||||||
|
|
||||||
|
public init(key: String, message: String, icon: String, position: ContextualTooltip.TooltipPosition) {
|
||||||
|
self.key = key
|
||||||
|
self.message = message
|
||||||
|
self.icon = icon
|
||||||
|
self.position = position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages the display of contextual tooltips with automatic dismissal and persistence
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
public final class TooltipManager {
|
||||||
|
private let onboarding: OnboardingState
|
||||||
|
|
||||||
|
/// Currently showing tooltip (nil if none showing)
|
||||||
|
public var currentTooltip: TooltipConfig? {
|
||||||
|
didSet {
|
||||||
|
// Mark the old tooltip as shown when replaced by a new one
|
||||||
|
if let oldValue = oldValue, oldValue.key != currentTooltip?.key {
|
||||||
|
onboarding.markHintShown(oldValue.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(onboarding: OnboardingState) {
|
||||||
|
self.onboarding = onboarding
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a tooltip with a delay
|
||||||
|
public func show(
|
||||||
|
key: String,
|
||||||
|
message: String,
|
||||||
|
icon: String = "lightbulb.fill",
|
||||||
|
position: ContextualTooltip.TooltipPosition = .bottom,
|
||||||
|
delay: TimeInterval = 1.0
|
||||||
|
) {
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
|
// Double-check hint hasn't been shown while we were waiting
|
||||||
|
guard onboarding.shouldShowHint(key) else { return }
|
||||||
|
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration)) {
|
||||||
|
currentTooltip = TooltipConfig(
|
||||||
|
key: key,
|
||||||
|
message: message,
|
||||||
|
icon: icon,
|
||||||
|
position: position
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismisses the current tooltip and marks it as shown
|
||||||
|
public func dismiss() {
|
||||||
|
if let current = currentTooltip {
|
||||||
|
onboarding.markHintShown(current.key)
|
||||||
|
}
|
||||||
|
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration)) {
|
||||||
|
currentTooltip = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View Modifier
|
||||||
|
|
||||||
|
public extension View {
|
||||||
|
/// Displays the current tooltip from a TooltipManager
|
||||||
|
func dynamicTooltip(_ manager: TooltipManager) -> some View {
|
||||||
|
self.overlay {
|
||||||
|
if let tooltip = manager.currentTooltip {
|
||||||
|
VStack {
|
||||||
|
if tooltip.position == .bottom {
|
||||||
|
Spacer()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
ContextualTooltip(
|
||||||
|
message: tooltip.message,
|
||||||
|
icon: tooltip.icon,
|
||||||
|
position: tooltip.position,
|
||||||
|
onDismiss: {
|
||||||
|
manager.dismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.horizontal, CasinoDesign.Spacing.medium)
|
||||||
|
.transition(.move(edge: tooltip.position == .bottom ? .bottom : .top).combined(with: .opacity))
|
||||||
|
|
||||||
|
if tooltip.position == .top {
|
||||||
|
Spacer()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.zIndex(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -1026,6 +1026,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Got it" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Hearts" : {
|
"Hearts" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1782,6 +1785,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Show Me How" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Six" : {
|
"Six" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1872,6 +1878,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Start Playing" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Statistics" : {
|
"Statistics" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -2170,6 +2179,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Welcome to %@!" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"WIN" : {
|
"WIN" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|||||||
@ -37,7 +37,11 @@ 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
|
||||||
CasinoDesign.debugLog("CloudSyncManager: iCloud available = \(available), token = \(String(describing: token))")
|
|
||||||
|
// Note: NSUbiquitousKeyValueStore only requires iCloud sign-in (token)
|
||||||
|
// It does NOT require iCloud Drive/Documents to be enabled
|
||||||
|
// So we only check for the token, not the container
|
||||||
|
CasinoDesign.debugLog("CloudSyncManager: iCloud available = \(available), token = \(token != nil ? "present" : "nil")")
|
||||||
return available
|
return available
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,7 +65,29 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
|
|
||||||
private let iCloudStore = NSUbiquitousKeyValueStore.default
|
/// Cached reference to iCloud store, lazily initialized
|
||||||
|
private var _iCloudStore: NSUbiquitousKeyValueStore?
|
||||||
|
private var _iCloudStoreInitialized = false
|
||||||
|
|
||||||
|
private var iCloudStore: NSUbiquitousKeyValueStore? {
|
||||||
|
// Return cached value if already attempted initialization
|
||||||
|
if _iCloudStoreInitialized {
|
||||||
|
return _iCloudStore
|
||||||
|
}
|
||||||
|
|
||||||
|
_iCloudStoreInitialized = true
|
||||||
|
|
||||||
|
// Only access the store if iCloud is actually available
|
||||||
|
guard iCloudAvailable else {
|
||||||
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud not available, skipping NSUbiquitousKeyValueStore access")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: Accessing NSUbiquitousKeyValueStore.default...")
|
||||||
|
_iCloudStore = NSUbiquitousKeyValueStore.default
|
||||||
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: NSUbiquitousKeyValueStore.default accessed successfully")
|
||||||
|
return _iCloudStore
|
||||||
|
}
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
@ -87,26 +113,28 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
UserDefaults.standard.set(true, forKey: iCloudEnabledKey)
|
UserDefaults.standard.set(true, forKey: iCloudEnabledKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register for iCloud changes BEFORE syncing
|
// Register for iCloud changes BEFORE syncing (only if available)
|
||||||
NotificationCenter.default.addObserver(
|
if iCloudAvailable, let store = iCloudStore {
|
||||||
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
NotificationCenter.default.addObserver(
|
||||||
object: iCloudStore,
|
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
||||||
queue: .main
|
object: store,
|
||||||
) { [weak self] notification in
|
queue: .main
|
||||||
// Extract values before crossing isolation boundary (for Sendable compliance)
|
) { [weak self] notification in
|
||||||
guard let userInfo = notification.userInfo,
|
// Extract values before crossing isolation boundary (for Sendable compliance)
|
||||||
let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int,
|
guard let userInfo = notification.userInfo,
|
||||||
let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else {
|
let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int,
|
||||||
return
|
let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.handleCloudChange(reason: reason, changedKeys: changedKeys)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Task { @MainActor in
|
|
||||||
self?.handleCloudChange(reason: reason, changedKeys: changedKeys)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger iCloud sync FIRST (before loading local)
|
// Trigger iCloud sync FIRST (before loading local)
|
||||||
if iCloudAvailable && iCloudEnabled {
|
if iCloudEnabled {
|
||||||
iCloudStore.synchronize()
|
store.synchronize()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load data (may get updated when iCloud sync completes)
|
// Load data (may get updated when iCloud sync completes)
|
||||||
@ -129,14 +157,20 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
CasinoDesign.debugLog("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)
|
||||||
|
guard let store = iCloudStore else {
|
||||||
|
CasinoDesign.debugLog("CloudSyncManager[\(T.gameIdentifier)]: iCloud store unavailable")
|
||||||
|
hasCompletedInitialSync = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var syncResult = false
|
var syncResult = false
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
syncResult = iCloudStore.synchronize()
|
syncResult = store.synchronize()
|
||||||
}
|
}
|
||||||
CasinoDesign.debugLog("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 = store.dictionaryRepresentation.keys
|
||||||
CasinoDesign.debugLog("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
|
||||||
@ -186,10 +220,10 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
UserDefaults.standard.set(encoded, forKey: localKey)
|
UserDefaults.standard.set(encoded, forKey: localKey)
|
||||||
|
|
||||||
// Save to iCloud
|
// Save to iCloud
|
||||||
if iCloudAvailable && iCloudEnabled {
|
if iCloudAvailable && iCloudEnabled, let store = iCloudStore {
|
||||||
iCloudStore.set(encoded, forKey: cloudKey)
|
store.set(encoded, forKey: cloudKey)
|
||||||
iCloudStore.set(Date(), forKey: syncDateKey)
|
store.set(Date(), forKey: syncDateKey)
|
||||||
iCloudStore.synchronize()
|
store.synchronize()
|
||||||
lastSyncDate = Date()
|
lastSyncDate = Date()
|
||||||
syncStatus = "Synced"
|
syncStatus = "Synced"
|
||||||
}
|
}
|
||||||
@ -258,12 +292,13 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
|
|
||||||
private func loadCloud() -> T? {
|
private func loadCloud() -> T? {
|
||||||
guard iCloudAvailable && iCloudEnabled,
|
guard iCloudAvailable && iCloudEnabled,
|
||||||
let data = iCloudStore.data(forKey: cloudKey),
|
let store = iCloudStore,
|
||||||
|
let data = store.data(forKey: cloudKey),
|
||||||
let decoded = try? decoder.decode(T.self, from: data) else {
|
let decoded = try? decoder.decode(T.self, from: data) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if let syncDate = iCloudStore.object(forKey: syncDateKey) as? Date {
|
if let syncDate = store.object(forKey: syncDateKey) as? Date {
|
||||||
lastSyncDate = syncDate
|
lastSyncDate = syncDate
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,10 +314,15 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let store = iCloudStore else {
|
||||||
|
syncStatus = "iCloud unavailable"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isSyncing = true
|
isSyncing = true
|
||||||
syncStatus = "Syncing..."
|
syncStatus = "Syncing..."
|
||||||
|
|
||||||
iCloudStore.synchronize()
|
store.synchronize()
|
||||||
|
|
||||||
// Reload to get any changes
|
// Reload to get any changes
|
||||||
let latestData = load()
|
let latestData = load()
|
||||||
@ -347,10 +387,10 @@ public final class CloudSyncManager<T: PersistableGameData> {
|
|||||||
public func reset() {
|
public func reset() {
|
||||||
UserDefaults.standard.removeObject(forKey: localKey)
|
UserDefaults.standard.removeObject(forKey: localKey)
|
||||||
|
|
||||||
if iCloudAvailable {
|
if iCloudAvailable, let store = iCloudStore {
|
||||||
iCloudStore.removeObject(forKey: cloudKey)
|
store.removeObject(forKey: cloudKey)
|
||||||
iCloudStore.removeObject(forKey: syncDateKey)
|
store.removeObject(forKey: syncDateKey)
|
||||||
iCloudStore.synchronize()
|
store.synchronize()
|
||||||
}
|
}
|
||||||
|
|
||||||
data = T.empty
|
data = T.empty
|
||||||
|
|||||||
@ -13,13 +13,13 @@ public enum CasinoDesign {
|
|||||||
// MARK: - Debug
|
// MARK: - Debug
|
||||||
|
|
||||||
/// Set to true to enable debug logging in CasinoKit.
|
/// Set to true to enable debug logging in CasinoKit.
|
||||||
public static let showDebugLogs = false
|
public static let showDebugLogs = true
|
||||||
|
|
||||||
/// Logs a message only in debug builds when `showDebugLogs` is enabled.
|
/// Logs a message only in debug builds when `showDebugLogs` is enabled.
|
||||||
public static func debugLog(_ message: String) {
|
public static func debugLog(_ message: String) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if showDebugLogs {
|
if showDebugLogs {
|
||||||
print(message)
|
print("[CasinoKit] \(message)")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
164
CasinoKit/Sources/CasinoKit/Views/ContextualTooltip.swift
Normal file
164
CasinoKit/Sources/CasinoKit/Views/ContextualTooltip.swift
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
//
|
||||||
|
// ContextualTooltip.swift
|
||||||
|
// CasinoKit
|
||||||
|
//
|
||||||
|
// Contextual tooltip for showing hints and tips to users.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A tooltip that appears to guide users through features.
|
||||||
|
public struct ContextualTooltip: View {
|
||||||
|
let message: String
|
||||||
|
let icon: String
|
||||||
|
let position: TooltipPosition
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
@ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = CasinoDesign.BaseFontSize.body
|
||||||
|
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = CasinoDesign.IconSize.small
|
||||||
|
|
||||||
|
public enum TooltipPosition {
|
||||||
|
case top, bottom, leading, trailing
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
message: String,
|
||||||
|
icon: String = "lightbulb.fill",
|
||||||
|
position: TooltipPosition = .top,
|
||||||
|
onDismiss: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.message = message
|
||||||
|
self.icon = icon
|
||||||
|
self.position = position
|
||||||
|
self.onDismiss = onDismiss
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack(spacing: CasinoDesign.Spacing.small) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: iconSize))
|
||||||
|
.foregroundStyle(Color.Sheet.accent)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: bodyFontSize))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onDismiss()
|
||||||
|
} label: {
|
||||||
|
Text("Got it")
|
||||||
|
.font(.system(size: bodyFontSize, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.Sheet.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(CasinoDesign.Spacing.medium)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
|
||||||
|
.fill(Color.black.opacity(CasinoDesign.Opacity.almostFull))
|
||||||
|
.shadow(
|
||||||
|
color: .black.opacity(CasinoDesign.Opacity.medium),
|
||||||
|
radius: CasinoDesign.Shadow.radiusLarge,
|
||||||
|
y: CasinoDesign.Shadow.offsetMedium
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, CasinoDesign.Spacing.medium)
|
||||||
|
.transition(.move(edge: edgeForPosition).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var edgeForPosition: Edge {
|
||||||
|
switch position {
|
||||||
|
case .top: return .top
|
||||||
|
case .bottom: return .bottom
|
||||||
|
case .leading: return .leading
|
||||||
|
case .trailing: return .trailing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modifier to show a contextual tooltip above a view.
|
||||||
|
public struct ContextualTooltipModifier: ViewModifier {
|
||||||
|
let message: String
|
||||||
|
let icon: String
|
||||||
|
let position: ContextualTooltip.TooltipPosition
|
||||||
|
@Binding var isShowing: Bool
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
public func body(content: Content) -> some View {
|
||||||
|
ZStack(alignment: alignmentForPosition) {
|
||||||
|
content
|
||||||
|
|
||||||
|
if isShowing {
|
||||||
|
ContextualTooltip(
|
||||||
|
message: message,
|
||||||
|
icon: icon,
|
||||||
|
position: position,
|
||||||
|
onDismiss: {
|
||||||
|
withAnimation(.spring(duration: CasinoDesign.Animation.quick)) {
|
||||||
|
isShowing = false
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.offset(offsetForPosition)
|
||||||
|
.zIndex(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var alignmentForPosition: Alignment {
|
||||||
|
switch position {
|
||||||
|
case .top: return .top
|
||||||
|
case .bottom: return .bottom
|
||||||
|
case .leading: return .leading
|
||||||
|
case .trailing: return .trailing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var offsetForPosition: CGSize {
|
||||||
|
switch position {
|
||||||
|
case .top: return CGSize(width: 0, height: -CasinoDesign.Spacing.small)
|
||||||
|
case .bottom: return CGSize(width: 0, height: CasinoDesign.Spacing.small)
|
||||||
|
case .leading: return CGSize(width: -CasinoDesign.Spacing.small, height: 0)
|
||||||
|
case .trailing: return CGSize(width: CasinoDesign.Spacing.small, height: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension View {
|
||||||
|
/// Shows a contextual tooltip when the binding is true.
|
||||||
|
func contextualTooltip(
|
||||||
|
_ message: String,
|
||||||
|
icon: String = "lightbulb.fill",
|
||||||
|
position: ContextualTooltip.TooltipPosition = .top,
|
||||||
|
isShowing: Binding<Bool>,
|
||||||
|
onDismiss: @escaping () -> Void = {}
|
||||||
|
) -> some View {
|
||||||
|
modifier(ContextualTooltipModifier(
|
||||||
|
message: message,
|
||||||
|
icon: icon,
|
||||||
|
position: position,
|
||||||
|
isShowing: isShowing,
|
||||||
|
onDismiss: onDismiss
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
Text("Some Content")
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contextualTooltip(
|
||||||
|
"This is a helpful tip that appears at the right moment!",
|
||||||
|
position: .top,
|
||||||
|
isShowing: .constant(true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
70
CasinoKit/Sources/CasinoKit/Views/PulsingModifier.swift
Normal file
70
CasinoKit/Sources/CasinoKit/Views/PulsingModifier.swift
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
//
|
||||||
|
// PulsingModifier.swift
|
||||||
|
// CasinoKit
|
||||||
|
//
|
||||||
|
// Visual hint modifier that pulses to draw attention.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Modifier that adds a pulsing animation to draw attention to interactive elements.
|
||||||
|
public struct PulsingModifier: ViewModifier {
|
||||||
|
let isActive: Bool
|
||||||
|
let color: Color
|
||||||
|
let scale: CGFloat
|
||||||
|
|
||||||
|
@State private var animationAmount: CGFloat = 1.0
|
||||||
|
|
||||||
|
public func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.overlay {
|
||||||
|
if isActive {
|
||||||
|
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.large)
|
||||||
|
.stroke(color, lineWidth: CasinoDesign.LineWidth.medium)
|
||||||
|
.scaleEffect(animationAmount)
|
||||||
|
.opacity(2 - animationAmount)
|
||||||
|
.animation(
|
||||||
|
.easeInOut(duration: 1.5)
|
||||||
|
.repeatForever(autoreverses: false),
|
||||||
|
value: animationAmount
|
||||||
|
)
|
||||||
|
.onAppear {
|
||||||
|
animationAmount = scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension View {
|
||||||
|
/// Adds a pulsing animation to draw attention to the view.
|
||||||
|
func pulsing(
|
||||||
|
isActive: Bool,
|
||||||
|
color: Color = .white,
|
||||||
|
scale: CGFloat = 1.3
|
||||||
|
) -> some View {
|
||||||
|
modifier(PulsingModifier(isActive: isActive, color: color, scale: scale))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
|
||||||
|
VStack(spacing: CasinoDesign.Spacing.xLarge) {
|
||||||
|
Button("Tap Here") {
|
||||||
|
// Action
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.Sheet.accent)
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
.clipShape(.rect(cornerRadius: CasinoDesign.CornerRadius.medium))
|
||||||
|
.pulsing(isActive: true, color: .white)
|
||||||
|
|
||||||
|
Text("Pulsing highlights interactive areas")
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
174
CasinoKit/Sources/CasinoKit/Views/WelcomeSheet.swift
Normal file
174
CasinoKit/Sources/CasinoKit/Views/WelcomeSheet.swift
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
//
|
||||||
|
// WelcomeSheet.swift
|
||||||
|
// CasinoKit
|
||||||
|
//
|
||||||
|
// First-launch welcome sheet for casino games.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Welcome sheet shown on first launch of a game.
|
||||||
|
public struct WelcomeSheet: View {
|
||||||
|
let gameName: String
|
||||||
|
let features: [WelcomeFeature]
|
||||||
|
let onStartTutorial: () -> Void
|
||||||
|
let onStartPlaying: () -> Void
|
||||||
|
|
||||||
|
@ScaledMetric(relativeTo: .largeTitle) private var titleSize: CGFloat = CasinoDesign.BaseFontSize.xxLarge
|
||||||
|
@ScaledMetric(relativeTo: .title2) private var featureTitleSize: CGFloat = CasinoDesign.BaseFontSize.subheadline
|
||||||
|
@ScaledMetric(relativeTo: .body) private var bodySize: CGFloat = CasinoDesign.BaseFontSize.body
|
||||||
|
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = CasinoDesign.IconSize.large
|
||||||
|
@ScaledMetric(relativeTo: .body) private var buttonPadding: CGFloat = CasinoDesign.Spacing.medium
|
||||||
|
|
||||||
|
public init(
|
||||||
|
gameName: String,
|
||||||
|
features: [WelcomeFeature],
|
||||||
|
onStartTutorial: @escaping () -> Void,
|
||||||
|
onStartPlaying: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.gameName = gameName
|
||||||
|
self.features = features
|
||||||
|
self.onStartTutorial = onStartTutorial
|
||||||
|
self.onStartPlaying = onStartPlaying
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
SheetContainerView(
|
||||||
|
title: String(localized: "Welcome to \(gameName)!", bundle: .module),
|
||||||
|
content: {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: CasinoDesign.Spacing.xLarge) {
|
||||||
|
// Game icon/emoji
|
||||||
|
Text(gameEmoji)
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.padding(.top, CasinoDesign.Spacing.medium)
|
||||||
|
|
||||||
|
// Features list
|
||||||
|
VStack(spacing: CasinoDesign.Spacing.large) {
|
||||||
|
ForEach(features) { feature in
|
||||||
|
FeatureRow(
|
||||||
|
feature: feature,
|
||||||
|
iconSize: iconSize,
|
||||||
|
titleSize: featureTitleSize,
|
||||||
|
bodySize: bodySize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
VStack(spacing: CasinoDesign.Spacing.medium) {
|
||||||
|
Button(action: onStartTutorial) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "play.circle.fill")
|
||||||
|
Text("Show Me How")
|
||||||
|
}
|
||||||
|
.font(.system(size: bodySize, weight: .semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(buttonPadding)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.large)
|
||||||
|
.fill(Color.Sheet.accent)
|
||||||
|
)
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: onStartPlaying) {
|
||||||
|
Text("Start Playing")
|
||||||
|
.font(.system(size: bodySize, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, CasinoDesign.Spacing.large)
|
||||||
|
.padding(.bottom, CasinoDesign.Spacing.xLarge)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, CasinoDesign.Spacing.large)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gameEmoji: String {
|
||||||
|
switch gameName.lowercased() {
|
||||||
|
case "blackjack": return "🃏"
|
||||||
|
case "baccarat": return "🎴"
|
||||||
|
default: return "🎰"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Feature Row
|
||||||
|
|
||||||
|
private struct FeatureRow: View {
|
||||||
|
let feature: WelcomeFeature
|
||||||
|
let iconSize: CGFloat
|
||||||
|
let titleSize: CGFloat
|
||||||
|
let bodySize: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: CasinoDesign.Spacing.medium) {
|
||||||
|
// Icon
|
||||||
|
Image(systemName: feature.icon)
|
||||||
|
.font(.system(size: iconSize))
|
||||||
|
.foregroundStyle(Color.Sheet.accent)
|
||||||
|
.frame(width: 40, alignment: .center)
|
||||||
|
|
||||||
|
// Text
|
||||||
|
VStack(alignment: .leading, spacing: CasinoDesign.Spacing.xxSmall) {
|
||||||
|
Text(feature.title)
|
||||||
|
.font(.system(size: titleSize, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(feature.description)
|
||||||
|
.font(.system(size: bodySize))
|
||||||
|
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Welcome Feature Model
|
||||||
|
|
||||||
|
public struct WelcomeFeature: Identifiable {
|
||||||
|
public let id = UUID()
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let description: String
|
||||||
|
|
||||||
|
public init(icon: String, title: String, description: String) {
|
||||||
|
self.icon = icon
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
WelcomeSheet(
|
||||||
|
gameName: "Blackjack",
|
||||||
|
features: [
|
||||||
|
WelcomeFeature(
|
||||||
|
icon: "target",
|
||||||
|
title: "Beat the Dealer",
|
||||||
|
description: "Get closer to 21 than the dealer without going over"
|
||||||
|
),
|
||||||
|
WelcomeFeature(
|
||||||
|
icon: "lightbulb.fill",
|
||||||
|
title: "Learn Strategy",
|
||||||
|
description: "Built-in hints show optimal plays based on basic strategy"
|
||||||
|
),
|
||||||
|
WelcomeFeature(
|
||||||
|
icon: "dollarsign.circle",
|
||||||
|
title: "Practice Free",
|
||||||
|
description: "Start with $1,000 and play risk-free"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
onStartTutorial: {},
|
||||||
|
onStartPlaying: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
230
ONBOARDING_IMPLEMENTATION.md
Normal file
230
ONBOARDING_IMPLEMENTATION.md
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
# Onboarding System Implementation Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A comprehensive, non-intrusive onboarding system has been implemented for both Blackjack and Baccarat games. The system provides a great first-time user experience without annoying experienced players.
|
||||||
|
|
||||||
|
## Components Created (in CasinoKit)
|
||||||
|
|
||||||
|
### 1. OnboardingState.swift
|
||||||
|
- Tracks first-time user progress
|
||||||
|
- Manages which hints have been shown
|
||||||
|
- Persists state to UserDefaults
|
||||||
|
- Supports tutorial mode (replay hints)
|
||||||
|
- Game-specific identifiers (separate state per game)
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `shouldShowHint(key:)` - Check if hint should be displayed
|
||||||
|
- `markHintShown(key:)` - Mark hint as seen (persisted)
|
||||||
|
- `completeWelcome()` - Mark welcome sheet as completed
|
||||||
|
- `startTutorialMode()` / `endTutorialMode()` - Enable/disable tutorial replay
|
||||||
|
- `reset()` - Clear all onboarding data (for testing)
|
||||||
|
|
||||||
|
### 2. WelcomeSheet.swift
|
||||||
|
- First-launch welcome screen
|
||||||
|
- Lists key features with icons
|
||||||
|
- Two CTAs: "Show Me How" (tutorial) or "Start Playing" (skip)
|
||||||
|
- Automatically shows game emoji (🃏 for Blackjack, 🎴 for Baccarat)
|
||||||
|
- Fully localized
|
||||||
|
|
||||||
|
### 3. ContextualTooltip.swift
|
||||||
|
- In-game hint tooltips
|
||||||
|
- Appears at the right moment
|
||||||
|
- "Got it" button to dismiss
|
||||||
|
- Animated entry/exit
|
||||||
|
- Automatically marks hint as shown on dismiss
|
||||||
|
- View modifier for easy integration
|
||||||
|
|
||||||
|
### 4. PulsingModifier.swift
|
||||||
|
- Visual pulse animation to draw attention
|
||||||
|
- Optional enhancement for interactive elements
|
||||||
|
- Configurable color and scale
|
||||||
|
- Currently available but not actively used (can be added later)
|
||||||
|
|
||||||
|
## Integration in Games
|
||||||
|
|
||||||
|
### Blackjack
|
||||||
|
|
||||||
|
**Welcome Sheet Features:**
|
||||||
|
1. Beat the Dealer - Get closer to 21
|
||||||
|
2. Learn Strategy - Built-in hints
|
||||||
|
3. Practice Free - Start with $1,000
|
||||||
|
4. Customize Rules - Change settings
|
||||||
|
|
||||||
|
**Contextual Hints:**
|
||||||
|
- **Betting Zone**: "Tap chips, then tap here to bet" (when entering betting phase for first time)
|
||||||
|
- **Deal Button**: "Tap Deal to start the round" (after placing first bet)
|
||||||
|
- **Player Actions**: "Choose your action based on the hint above" (during first player turn)
|
||||||
|
|
||||||
|
### Baccarat
|
||||||
|
|
||||||
|
**Welcome Sheet Features:**
|
||||||
|
1. Bet on Player, Banker, or Tie
|
||||||
|
2. Track Patterns - Road maps show history
|
||||||
|
3. Practice Free - Start with $1,000
|
||||||
|
4. Customize Settings - Change table limits
|
||||||
|
|
||||||
|
**Contextual Hints:**
|
||||||
|
- **Betting Zone**: "Tap chips, then tap a betting zone" (when entering betting phase for first time)
|
||||||
|
- **Deal Button**: "Tap Deal to start the round" (after placing first bet)
|
||||||
|
- **Result Display**: "Results appear here, then in the road maps below" (after first round completes)
|
||||||
|
|
||||||
|
## Settings Changes
|
||||||
|
|
||||||
|
Both games now default to more beginner-friendly settings:
|
||||||
|
|
||||||
|
| Setting | Old Default | New Default |
|
||||||
|
|---------|-------------|-------------|
|
||||||
|
| Table Limits | Low Stakes ($10-$1,000) | Casual ($5-$500) |
|
||||||
|
| Starting Balance | $10,000 | $1,000 |
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Lower minimum bet ($5) is less intimidating for beginners
|
||||||
|
- $1,000 balance creates more meaningful decisions (not too much/little)
|
||||||
|
- Still allows ~200 hands at $5/hand for good learning experience
|
||||||
|
- Users can always increase in settings
|
||||||
|
|
||||||
|
## Bug Fix
|
||||||
|
|
||||||
|
**Blackjack Late Surrender:**
|
||||||
|
- Fixed inconsistency where `resetToDefaults()` set it to `true` but Vegas preset overrode to `false`
|
||||||
|
- Now correctly defaults to `false` to match Vegas rules
|
||||||
|
|
||||||
|
## User Flow
|
||||||
|
|
||||||
|
### First Launch:
|
||||||
|
1. App loads
|
||||||
|
2. Welcome sheet appears automatically after 500ms delay
|
||||||
|
3. User chooses:
|
||||||
|
- **"Show Me How"**: Enables tutorial mode, shows contextual hints during first round
|
||||||
|
- **"Start Playing"**: Skips tutorial, hints still appear once naturally during gameplay
|
||||||
|
|
||||||
|
### Subsequent Launches:
|
||||||
|
- Welcome sheet never shows again
|
||||||
|
- Contextual hints appear once at the right moment
|
||||||
|
- Each hint only shows once per hint key
|
||||||
|
- Completely transparent to returning users
|
||||||
|
|
||||||
|
### Tutorial Mode:
|
||||||
|
- User can replay tutorial by tapping "Show Me How" on welcome
|
||||||
|
- All hints re-enabled for that session
|
||||||
|
- Tutorial mode doesn't persist across app launches
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Hint Timing
|
||||||
|
- Hints are shown with delays using `Task.sleep(for:)` on MainActor
|
||||||
|
- Animations use spring duration for smooth transitions
|
||||||
|
- onChange modifiers trigger hints based on game state changes
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
- Uses UserDefaults with game-specific keys
|
||||||
|
- Format: `"onboarding.{gameIdentifier}.{property}"`
|
||||||
|
- Examples: `"onboarding.blackjack.hasLaunched"`, `"onboarding.baccarat.hintsShown"`
|
||||||
|
|
||||||
|
### Thread Safety
|
||||||
|
- All onboarding state is @MainActor
|
||||||
|
- Safe to use from SwiftUI views
|
||||||
|
- No threading concerns
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
|
||||||
|
### Blackjack README
|
||||||
|
- Added "First-Time User Experience" section at top of features
|
||||||
|
- Documents welcome sheet, tutorial mode, and contextual hints
|
||||||
|
|
||||||
|
### Baccarat README
|
||||||
|
- Added "First-Time User Experience" section at top of features
|
||||||
|
- Same structure as Blackjack for consistency
|
||||||
|
|
||||||
|
### CasinoKit README
|
||||||
|
- New "Onboarding & Tutorials" section with full API documentation
|
||||||
|
- Code examples for WelcomeSheet, ContextualTooltip, OnboardingState
|
||||||
|
- Updated file structure to show new components
|
||||||
|
- Added Blackjack to "Apps Using CasinoKit" list
|
||||||
|
|
||||||
|
## Best Practices Followed
|
||||||
|
|
||||||
|
✅ **Non-intrusive**: All onboarding is skippable
|
||||||
|
✅ **One-time only**: Hints never show twice (unless tutorial mode)
|
||||||
|
✅ **Right moment**: Hints appear contextually, not all at once
|
||||||
|
✅ **Short & visual**: Messages are concise with icons
|
||||||
|
✅ **Localized**: All strings use String Catalog
|
||||||
|
✅ **Accessible**: Uses standard SwiftUI components
|
||||||
|
✅ **Persistent**: User's progress is saved
|
||||||
|
✅ **Game-specific**: Each game has independent onboarding state
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
|
||||||
|
### Manual Testing:
|
||||||
|
1. **Fresh install**: Delete app, reinstall, verify welcome sheet appears
|
||||||
|
2. **Tutorial mode**: Tap "Show Me How", verify hints appear in sequence
|
||||||
|
3. **Skip mode**: Tap "Start Playing", verify hints still appear once naturally
|
||||||
|
4. **Second launch**: Relaunch app, verify welcome doesn't show again
|
||||||
|
5. **Settings reset**: Check if onboarding can be reset (could add to settings)
|
||||||
|
|
||||||
|
### Reset for Testing:
|
||||||
|
Add this to a development menu if needed:
|
||||||
|
```swift
|
||||||
|
Button("Reset Onboarding") {
|
||||||
|
gameState.onboarding.reset()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements (Not Implemented)
|
||||||
|
|
||||||
|
These could be added later if desired:
|
||||||
|
|
||||||
|
1. **Progressive discovery hints**: Tips after 5/10/20 hands
|
||||||
|
- "Enable card counting in settings" (Blackjack)
|
||||||
|
- "Side bets offer bigger payouts" (both games)
|
||||||
|
|
||||||
|
2. **Onboarding analytics**: Track which hints are most helpful
|
||||||
|
|
||||||
|
3. **User-requested replay**: "Show tutorial again" in settings
|
||||||
|
|
||||||
|
4. **Pulsing hints**: Use PulsingModifier on interactive elements during tutorial
|
||||||
|
|
||||||
|
5. **Accessibility announcements**: Post VoiceOver announcements for key moments
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
```
|
||||||
|
CasinoKit/Sources/CasinoKit/
|
||||||
|
├── Models/OnboardingState.swift
|
||||||
|
└── Views/
|
||||||
|
├── ContextualTooltip.swift
|
||||||
|
├── WelcomeSheet.swift
|
||||||
|
└── PulsingModifier.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
```
|
||||||
|
Blackjack/
|
||||||
|
├── Models/GameSettings.swift (defaults changed, bug fix)
|
||||||
|
├── Engine/GameState.swift (added onboarding property)
|
||||||
|
└── Views/Game/GameTableView.swift (integrated onboarding UI)
|
||||||
|
|
||||||
|
Baccarat/
|
||||||
|
├── Models/GameSettings.swift (defaults changed)
|
||||||
|
├── Engine/GameState.swift (added onboarding property)
|
||||||
|
└── Views/Game/GameTableView.swift (integrated onboarding UI)
|
||||||
|
|
||||||
|
README Files:
|
||||||
|
├── Blackjack/README.md
|
||||||
|
├── Baccarat/README.md
|
||||||
|
└── CasinoKit/README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The onboarding system provides a polished first-time user experience that:
|
||||||
|
- Helps new users understand the game quickly
|
||||||
|
- Never annoys experienced players
|
||||||
|
- Maintains the premium feel of the apps
|
||||||
|
- Follows iOS best practices
|
||||||
|
- Is fully reusable for future casino games
|
||||||
|
|
||||||
|
All code follows the project's Swift/SwiftUI guidelines, uses design constants, and includes proper accessibility support.
|
||||||
|
|
||||||
246
TOOLTIP_REFACTORING.md
Normal file
246
TOOLTIP_REFACTORING.md
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
# Tooltip System Refactoring
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The tooltip management system has been refactored from game-specific implementations to a generic, reusable system in CasinoKit.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The original implementation had several issues:
|
||||||
|
- **Verbose**: Each game had duplicate `TooltipConfig` structs and complex state management
|
||||||
|
- **Not scalable**: Adding new tooltips required significant boilerplate
|
||||||
|
- **Repetitive logic**: Similar code duplicated across Blackjack and Baccarat
|
||||||
|
- **Difficult to maintain**: Changes required updates in multiple places
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Created a centralized `TooltipManager` class in CasinoKit that:
|
||||||
|
- Manages tooltip state generically
|
||||||
|
- Automatically handles dismiss tracking
|
||||||
|
- Ensures only one tooltip shows at a time
|
||||||
|
- Integrates seamlessly with `OnboardingState`
|
||||||
|
|
||||||
|
## New Components
|
||||||
|
|
||||||
|
### 1. `TooltipConfig` (CasinoKit)
|
||||||
|
|
||||||
|
A simple struct defining tooltip properties:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public struct TooltipConfig: Equatable {
|
||||||
|
public let key: String
|
||||||
|
public let message: String
|
||||||
|
public let icon: String
|
||||||
|
public let position: ContextualTooltip.TooltipPosition
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `TooltipManager` (CasinoKit)
|
||||||
|
|
||||||
|
An `@Observable` class that manages tooltip lifecycle:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
public final class TooltipManager {
|
||||||
|
private let onboarding: OnboardingState
|
||||||
|
public var currentTooltip: TooltipConfig?
|
||||||
|
|
||||||
|
public func show(
|
||||||
|
key: String,
|
||||||
|
message: String,
|
||||||
|
icon: String = "lightbulb.fill",
|
||||||
|
position: ContextualTooltip.TooltipPosition = .bottom,
|
||||||
|
delay: TimeInterval = 1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
public func dismiss()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- Automatic dismiss tracking via `didSet` on `currentTooltip`
|
||||||
|
- Delayed presentation with cancellation if hint already shown
|
||||||
|
- Respects `OnboardingState.shouldShowHint()` checks
|
||||||
|
- Single source of truth for current tooltip
|
||||||
|
|
||||||
|
### 3. `dynamicTooltip` View Modifier (CasinoKit)
|
||||||
|
|
||||||
|
A convenient view modifier that displays tooltips:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
public extension View {
|
||||||
|
func dynamicTooltip(_ manager: TooltipManager) -> some View
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in Games
|
||||||
|
|
||||||
|
### Before (Verbose)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// State variables for each tooltip
|
||||||
|
@State private var showBettingHint = false
|
||||||
|
@State private var showDealHint = false
|
||||||
|
@State private var showActionsHint = false
|
||||||
|
|
||||||
|
// Separate struct definition
|
||||||
|
private struct TooltipConfig {
|
||||||
|
let key: String
|
||||||
|
let message: String
|
||||||
|
let icon: String
|
||||||
|
let position: ContextualTooltip.TooltipPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complex state with tracking
|
||||||
|
@State private var currentTooltip: TooltipConfig? {
|
||||||
|
didSet {
|
||||||
|
if let oldValue = oldValue, oldValue.key != currentTooltip?.key {
|
||||||
|
state.onboarding.markHintShown(oldValue.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic helper with lots of parameters
|
||||||
|
private func showTooltip(key: String, message: String, icon: String, position: ContextualTooltip.TooltipPosition, delay: TimeInterval) {
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
|
guard state.onboarding.shouldShowHint(key) else { return }
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||||
|
currentTooltip = TooltipConfig(key: key, message: message, icon: icon, position: position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complex overlay in view body
|
||||||
|
.overlay {
|
||||||
|
if let tooltip = currentTooltip {
|
||||||
|
VStack {
|
||||||
|
// ... positioning logic
|
||||||
|
ContextualTooltip(
|
||||||
|
message: tooltip.message,
|
||||||
|
icon: tooltip.icon,
|
||||||
|
position: tooltip.position,
|
||||||
|
onDismiss: {
|
||||||
|
state.onboarding.markHintShown(tooltip.key)
|
||||||
|
withAnimation(.spring(duration: Design.Animation.springDuration)) {
|
||||||
|
currentTooltip = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// ... more positioning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (Clean)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Single state variable
|
||||||
|
@State private var tooltipManager: TooltipManager?
|
||||||
|
|
||||||
|
// Initialize in onAppear
|
||||||
|
.onAppear {
|
||||||
|
tooltipManager = TooltipManager(onboarding: state.onboarding)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple show calls
|
||||||
|
private func showBettingHint() {
|
||||||
|
tooltipManager?.show(
|
||||||
|
key: "bettingZone",
|
||||||
|
message: "Select a chip and tap here to bet",
|
||||||
|
icon: "hand.tap.fill",
|
||||||
|
position: .bottom,
|
||||||
|
delay: 1.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-line view modifier
|
||||||
|
.dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: state.onboarding))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Reduced code**: ~70 lines of boilerplate eliminated per game
|
||||||
|
2. **Better scalability**: Adding new tooltips is now trivial
|
||||||
|
3. **Single source of truth**: All tooltip logic in CasinoKit
|
||||||
|
4. **Easier maintenance**: Changes only need to be made in one place
|
||||||
|
5. **Reusable**: Any new game can use `TooltipManager` with minimal setup
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
### CasinoKit
|
||||||
|
- **Created**: `Sources/CasinoKit/Models/TooltipManager.swift`
|
||||||
|
- **Updated**: `Sources/CasinoKit/Exports.swift` (added TooltipManager export)
|
||||||
|
- **Updated**: `README.md` (added TooltipManager documentation)
|
||||||
|
|
||||||
|
### Blackjack
|
||||||
|
- **Updated**: `Views/Game/GameTableView.swift`
|
||||||
|
- Removed local `TooltipConfig` struct
|
||||||
|
- Removed `currentTooltip` @State with didSet
|
||||||
|
- Removed generic `showTooltip()` helper
|
||||||
|
- Added `@State private var tooltipManager: TooltipManager?`
|
||||||
|
- Simplified tooltip show methods
|
||||||
|
- Replaced complex overlay with `.dynamicTooltip()` modifier
|
||||||
|
|
||||||
|
### Baccarat
|
||||||
|
- **Updated**: `Views/Game/GameTableView.swift`
|
||||||
|
- Same changes as Blackjack
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
To use `TooltipManager` in a new game:
|
||||||
|
|
||||||
|
1. **Add state variable:**
|
||||||
|
```swift
|
||||||
|
@State private var tooltipManager: TooltipManager?
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Initialize in onAppear:**
|
||||||
|
```swift
|
||||||
|
.onAppear {
|
||||||
|
tooltipManager = TooltipManager(onboarding: gameState.onboarding)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Apply view modifier:**
|
||||||
|
```swift
|
||||||
|
.dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: gameState.onboarding))
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Show tooltips:**
|
||||||
|
```swift
|
||||||
|
tooltipManager?.show(
|
||||||
|
key: "uniqueKey",
|
||||||
|
message: "Your hint text",
|
||||||
|
icon: "lightbulb.fill",
|
||||||
|
position: .bottom,
|
||||||
|
delay: 1.0
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Both Blackjack and Baccarat have been built and tested successfully with the new system.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Blackjack
|
||||||
|
xcodebuild -workspace CasinoGames.xcworkspace -scheme Blackjack \
|
||||||
|
-configuration Debug -destination 'platform=iOS Simulator,id=...' build
|
||||||
|
# ✅ BUILD SUCCEEDED
|
||||||
|
|
||||||
|
# Baccarat
|
||||||
|
xcodebuild -workspace CasinoGames.xcworkspace -scheme Baccarat \
|
||||||
|
-configuration Debug -destination 'platform=iOS Simulator,id=...' build
|
||||||
|
# ✅ BUILD SUCCEEDED
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements:
|
||||||
|
- **Tooltip queue**: Allow queueing multiple tooltips
|
||||||
|
- **Positioning hints**: Auto-detect best position based on screen space
|
||||||
|
- **Animation customization**: Per-tooltip animation styles
|
||||||
|
- **Analytics**: Track which tooltips are most helpful
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user