diff --git a/Baccarat.xcodeproj/project.pbxproj b/Baccarat.xcodeproj/project.pbxproj index 582d8f5..7401db5 100644 --- a/Baccarat.xcodeproj/project.pbxproj +++ b/Baccarat.xcodeproj/project.pbxproj @@ -7,10 +7,25 @@ objects = { /* Begin PBXBuildFile section */ + EA5AD2012EF34B660040CB90 /* CasinoKit in Frameworks */ = {isa = PBXBuildFile; productRef = EA5AD2002EF34B660040CB90 /* CasinoKit */; }; EAD891262EF25181006DBA80 /* CasinoKit in Frameworks */ = {isa = PBXBuildFile; productRef = EAD891252EF25181006DBA80 /* CasinoKit */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + EA5AD1BE2EF346C50040CB90 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EAD890AF2EF1E9CE006DBA80 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EA5AD1B02EF346C40040CB90; + remoteInfo = Blackjack; + }; + EA5AD1C82EF346C50040CB90 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EAD890AF2EF1E9CE006DBA80 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EA5AD1B02EF346C40040CB90; + remoteInfo = Blackjack; + }; EAD890C52EF1E9CF006DBA80 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = EAD890AF2EF1E9CE006DBA80 /* Project object */; @@ -28,6 +43,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + EA5AD1B12EF346C40040CB90 /* Blackjack.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Blackjack.app; sourceTree = BUILT_PRODUCTS_DIR; }; + EA5AD1BD2EF346C50040CB90 /* BlackjackTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BlackjackTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + EA5AD1C72EF346C50040CB90 /* BlackjackUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BlackjackUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EAD890B72EF1E9CE006DBA80 /* Baccarat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Baccarat.app; sourceTree = BUILT_PRODUCTS_DIR; }; EAD890C42EF1E9CF006DBA80 /* BaccaratTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BaccaratTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EAD890CE2EF1E9CF006DBA80 /* BaccaratUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BaccaratUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -44,6 +62,21 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + EA5AD1B22EF346C40040CB90 /* Blackjack */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Blackjack; + sourceTree = ""; + }; + EA5AD1C02EF346C50040CB90 /* BlackjackTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = BlackjackTests; + sourceTree = ""; + }; + EA5AD1CA2EF346C50040CB90 /* BlackjackUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = BlackjackUITests; + sourceTree = ""; + }; EAD890B92EF1E9CE006DBA80 /* Baccarat */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -65,6 +98,28 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + EA5AD1AE2EF346C40040CB90 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + EA5AD2012EF34B660040CB90 /* CasinoKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA5AD1BA2EF346C50040CB90 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA5AD1C42EF346C50040CB90 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; EAD890B42EF1E9CE006DBA80 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -90,12 +145,23 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + EA5AD1FF2EF34B660040CB90 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; EAD890AE2EF1E9CE006DBA80 = { isa = PBXGroup; children = ( EAD890B92EF1E9CE006DBA80 /* Baccarat */, EAD890C72EF1E9CF006DBA80 /* BaccaratTests */, EAD890D12EF1E9CF006DBA80 /* BaccaratUITests */, + EA5AD1B22EF346C40040CB90 /* Blackjack */, + EA5AD1C02EF346C50040CB90 /* BlackjackTests */, + EA5AD1CA2EF346C50040CB90 /* BlackjackUITests */, + EA5AD1FF2EF34B660040CB90 /* Frameworks */, EAD890B82EF1E9CE006DBA80 /* Products */, ); sourceTree = ""; @@ -106,6 +172,9 @@ EAD890B72EF1E9CE006DBA80 /* Baccarat.app */, EAD890C42EF1E9CF006DBA80 /* BaccaratTests.xctest */, EAD890CE2EF1E9CF006DBA80 /* BaccaratUITests.xctest */, + EA5AD1B12EF346C40040CB90 /* Blackjack.app */, + EA5AD1BD2EF346C50040CB90 /* BlackjackTests.xctest */, + EA5AD1C72EF346C50040CB90 /* BlackjackUITests.xctest */, ); name = Products; sourceTree = ""; @@ -113,6 +182,75 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + EA5AD1B02EF346C40040CB90 /* Blackjack */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA5AD1D52EF346C50040CB90 /* Build configuration list for PBXNativeTarget "Blackjack" */; + buildPhases = ( + EA5AD1AD2EF346C40040CB90 /* Sources */, + EA5AD1AE2EF346C40040CB90 /* Frameworks */, + EA5AD1AF2EF346C40040CB90 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + EA5AD1B22EF346C40040CB90 /* Blackjack */, + ); + name = Blackjack; + packageProductDependencies = ( + EA5AD2002EF34B660040CB90 /* CasinoKit */, + ); + productName = Blackjack; + productReference = EA5AD1B12EF346C40040CB90 /* Blackjack.app */; + productType = "com.apple.product-type.application"; + }; + EA5AD1BC2EF346C50040CB90 /* BlackjackTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA5AD1D62EF346C50040CB90 /* Build configuration list for PBXNativeTarget "BlackjackTests" */; + buildPhases = ( + EA5AD1B92EF346C50040CB90 /* Sources */, + EA5AD1BA2EF346C50040CB90 /* Frameworks */, + EA5AD1BB2EF346C50040CB90 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + EA5AD1BF2EF346C50040CB90 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + EA5AD1C02EF346C50040CB90 /* BlackjackTests */, + ); + name = BlackjackTests; + packageProductDependencies = ( + ); + productName = BlackjackTests; + productReference = EA5AD1BD2EF346C50040CB90 /* BlackjackTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + EA5AD1C62EF346C50040CB90 /* BlackjackUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA5AD1D72EF346C50040CB90 /* Build configuration list for PBXNativeTarget "BlackjackUITests" */; + buildPhases = ( + EA5AD1C32EF346C50040CB90 /* Sources */, + EA5AD1C42EF346C50040CB90 /* Frameworks */, + EA5AD1C52EF346C50040CB90 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + EA5AD1C92EF346C50040CB90 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + EA5AD1CA2EF346C50040CB90 /* BlackjackUITests */, + ); + name = BlackjackUITests; + packageProductDependencies = ( + ); + productName = BlackjackUITests; + productReference = EA5AD1C72EF346C50040CB90 /* BlackjackUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; EAD890B62EF1E9CE006DBA80 /* Baccarat */ = { isa = PBXNativeTarget; buildConfigurationList = EAD890D82EF1E9CF006DBA80 /* Build configuration list for PBXNativeTarget "Baccarat" */; @@ -192,6 +330,17 @@ LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 2600; TargetAttributes = { + EA5AD1B02EF346C40040CB90 = { + CreatedOnToolsVersion = 26.0; + }; + EA5AD1BC2EF346C50040CB90 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = EA5AD1B02EF346C40040CB90; + }; + EA5AD1C62EF346C50040CB90 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = EA5AD1B02EF346C40040CB90; + }; EAD890B62EF1E9CE006DBA80 = { CreatedOnToolsVersion = 26.0; }; @@ -229,11 +378,35 @@ EAD890B62EF1E9CE006DBA80 /* Baccarat */, EAD890C32EF1E9CF006DBA80 /* BaccaratTests */, EAD890CD2EF1E9CF006DBA80 /* BaccaratUITests */, + EA5AD1B02EF346C40040CB90 /* Blackjack */, + EA5AD1BC2EF346C50040CB90 /* BlackjackTests */, + EA5AD1C62EF346C50040CB90 /* BlackjackUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + EA5AD1AF2EF346C40040CB90 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA5AD1BB2EF346C50040CB90 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA5AD1C52EF346C50040CB90 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; EAD890B52EF1E9CE006DBA80 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -258,6 +431,27 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + EA5AD1AD2EF346C40040CB90 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA5AD1B92EF346C50040CB90 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA5AD1C32EF346C50040CB90 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; EAD890B32EF1E9CE006DBA80 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -282,6 +476,16 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + EA5AD1BF2EF346C50040CB90 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EA5AD1B02EF346C40040CB90 /* Blackjack */; + targetProxy = EA5AD1BE2EF346C50040CB90 /* PBXContainerItemProxy */; + }; + EA5AD1C92EF346C50040CB90 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EA5AD1B02EF346C40040CB90 /* Blackjack */; + targetProxy = EA5AD1C82EF346C50040CB90 /* PBXContainerItemProxy */; + }; EAD890C62EF1E9CF006DBA80 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = EAD890B62EF1E9CE006DBA80 /* Baccarat */; @@ -295,6 +499,156 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + EA5AD1CF2EF346C50040CB90 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Blackjack/Blackjack.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.Blackjack; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + EA5AD1D02EF346C50040CB90 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Blackjack/Blackjack.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.Blackjack; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + EA5AD1D12EF346C50040CB90 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.BlackjackTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Blackjack.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Blackjack"; + }; + name = Debug; + }; + EA5AD1D22EF346C50040CB90 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.BlackjackTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Blackjack.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Blackjack"; + }; + name = Release; + }; + EA5AD1D32EF346C50040CB90 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.BlackjackUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Blackjack; + }; + name = Debug; + }; + EA5AD1D42EF346C50040CB90 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6R7KLBPBLZ; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.BlackjackUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Blackjack; + }; + name = Release; + }; EAD890D62EF1E9CF006DBA80 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -579,6 +933,33 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + EA5AD1D52EF346C50040CB90 /* Build configuration list for PBXNativeTarget "Blackjack" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA5AD1CF2EF346C50040CB90 /* Debug */, + EA5AD1D02EF346C50040CB90 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EA5AD1D62EF346C50040CB90 /* Build configuration list for PBXNativeTarget "BlackjackTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA5AD1D12EF346C50040CB90 /* Debug */, + EA5AD1D22EF346C50040CB90 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EA5AD1D72EF346C50040CB90 /* Build configuration list for PBXNativeTarget "BlackjackUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA5AD1D32EF346C50040CB90 /* Debug */, + EA5AD1D42EF346C50040CB90 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; EAD890B22EF1E9CE006DBA80 /* Build configuration list for PBXProject "Baccarat" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -625,6 +1006,11 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + EA5AD2002EF34B660040CB90 /* CasinoKit */ = { + isa = XCSwiftPackageProductDependency; + package = EAD891242EF25181006DBA80 /* XCLocalSwiftPackageReference "CasinoKit" */; + productName = CasinoKit; + }; EAD891252EF25181006DBA80 /* CasinoKit */ = { isa = XCSwiftPackageProductDependency; productName = CasinoKit; diff --git a/Baccarat.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist b/Baccarat.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist index 2fa6a7c..6f25dbb 100644 --- a/Baccarat.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Baccarat.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcschemes/xcschememanagement.plist @@ -5,6 +5,11 @@ SchemeUserState Baccarat.xcscheme_^#shared#^_ + + orderHint + 2 + + Blackjack.xcscheme_^#shared#^_ orderHint 1 diff --git a/Blackjack/Agents.md b/Blackjack/Agents.md new file mode 100644 index 0000000..f4bf715 --- /dev/null +++ b/Blackjack/Agents.md @@ -0,0 +1,308 @@ +# Agent guide for Swift and SwiftUI + +This repository contains an Xcode project written with Swift and SwiftUI. Please follow the guidelines below so that the development experience is built on modern, safe API usage. + + +## Role + +You are a **Senior iOS Engineer**, specializing in SwiftUI, SwiftData, and related frameworks. Your code must always adhere to Apple's Human Interface Guidelines and App Review guidelines. + + +## Core instructions + +- Target iOS 26.0 or later. (Yes, it definitely exists.) +- Swift 6.2 or later, using modern Swift concurrency. +- SwiftUI backed up by `@Observable` classes for shared data. +- Do not introduce third-party frameworks without asking first. +- Avoid UIKit unless requested. + + +## Swift instructions + +- Always mark `@Observable` classes with `@MainActor`. +- Assume strict Swift concurrency rules are being applied. +- Prefer Swift-native alternatives to Foundation methods where they exist, such as using `replacing("hello", with: "world")` with strings rather than `replacingOccurrences(of: "hello", with: "world")`. +- Prefer modern Foundation API, for example `URL.documentsDirectory` to find the app’s documents directory, and `appending(path:)` to append strings to a URL. +- Never use C-style number formatting such as `Text(String(format: "%.2f", abs(myNumber)))`; always use `Text(abs(change), format: .number.precision(.fractionLength(2)))` instead. +- Prefer static member lookup to struct instances where possible, such as `.circle` rather than `Circle()`, and `.borderedProminent` rather than `BorderedProminentButtonStyle()`. +- Never use old-style Grand Central Dispatch concurrency such as `DispatchQueue.main.async()`. If behavior like this is needed, always use modern Swift concurrency. +- Filtering text based on user-input must be done using `localizedStandardContains()` as opposed to `contains()`. +- Avoid force unwraps and force `try` unless it is unrecoverable. + + +## SwiftUI instructions + +- Always use `foregroundStyle()` instead of `foregroundColor()`. +- Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`. +- Always use the `Tab` API instead of `tabItem()`. +- Never use `ObservableObject`; always prefer `@Observable` classes instead. +- Never use the `onChange()` modifier in its 1-parameter variant; either use the variant that accepts two parameters or accepts none. +- Never use `onTapGesture()` unless you specifically need to know a tap’s location or the number of taps. All other usages should use `Button`. +- Never use `Task.sleep(nanoseconds:)`; always use `Task.sleep(for:)` instead. +- Never use `UIScreen.main.bounds` to read the size of the available space. +- Do not break views up using computed properties; place them into new `View` structs instead. +- Do not force specific font sizes; prefer using Dynamic Type instead. +- Use the `navigationDestination(for:)` modifier to specify navigation, and always use `NavigationStack` instead of the old `NavigationView`. +- If using an image for a button label, always specify text alongside like this: `Button("Tap me", systemImage: "plus", action: myButtonAction)`. +- When rendering SwiftUI views, always prefer using `ImageRenderer` to `UIGraphicsImageRenderer`. +- Don’t apply the `fontWeight()` modifier unless there is good reason. If you want to make some text bold, always use `bold()` instead of `fontWeight(.bold)`. +- Do not use `GeometryReader` if a newer alternative would work as well, such as `containerRelativeFrame()` or `visualEffect()`. +- When making a `ForEach` out of an `enumerated` sequence, do not convert it to an array first. So, prefer `ForEach(x.enumerated(), id: \.element.id)` instead of `ForEach(Array(x.enumerated()), id: \.element.id)`. +- When hiding scroll view indicators, use the `.scrollIndicators(.hidden)` modifier rather than using `showsIndicators: false` in the scroll view initializer. +- Place view logic into view models or similar, so it can be tested. +- Avoid `AnyView` unless it is absolutely required. +- **Never use raw numeric literals** for padding, spacing, opacity, font sizes, dimensions, corner radii, shadows, or animation durations—always use Design constants (see "No magic numbers" section). +- **Never use inline `Color(red:green:blue:)` or hex colors**—define all colors in the `Color` extension in `DesignConstants.swift` with semantic names. +- Avoid using UIKit colors in SwiftUI code. + + +## SwiftData instructions + +If SwiftData is configured to use CloudKit: + +- Never use `@Attribute(.unique)`. +- Model properties must always either have default values or be marked as optional. +- All relationships must be marked optional. + + +## Localization instructions + +- Use **String Catalogs** (`.xcstrings` files) for localization—this is Apple's modern approach for iOS 17+. +- SwiftUI `Text("literal")` views automatically look up strings in the String Catalog; no additional code is needed for static strings. +- For strings outside of `Text` views or with dynamic content, use `String(localized:)` or create a helper extension: + ```swift + extension String { + static func localized(_ key: String) -> String { + String(localized: String.LocalizationValue(key)) + } + static func localized(_ key: String, _ arguments: CVarArg...) -> String { + let format = String(localized: String.LocalizationValue(key)) + return String(format: format, arguments: arguments) + } + } + ``` +- For format strings with interpolation (e.g., "Balance: $%@"), define a key in the String Catalog and use `String.localized("key", value)`. +- Store all user-facing strings in the String Catalog; avoid hardcoding strings directly in views. +- Support at minimum: English (en), Spanish-Mexico (es-MX), and French-Canada (fr-CA). +- Never use `NSLocalizedString`; prefer the modern `String(localized:)` API. + + +## No magic numbers or hardcoded values + +**Never use raw numeric literals or hardcoded colors directly in views.** All values must be extracted to named constants, enums, or variables. This applies to: + +### Values that MUST be constants: +- **Spacing & Padding**: `.padding(Design.Spacing.medium)` not `.padding(12)` +- **Corner Radii**: `Design.CornerRadius.large` not `cornerRadius: 16` +- **Font Sizes**: `Design.BaseFontSize.body` not `size: 14` +- **Opacity Values**: `Design.Opacity.strong` not `.opacity(0.7)` +- **Colors**: `Color.Primary.accent` not `Color(red: 0.8, green: 0.6, blue: 0.2)` +- **Line Widths**: `Design.LineWidth.medium` not `lineWidth: 2` +- **Shadow Values**: `Design.Shadow.radiusLarge` not `radius: 10` +- **Animation Durations**: `Design.Animation.quick` not `duration: 0.3` +- **Component Sizes**: `Design.Size.chipBadge` not `frame(width: 32)` + +### What to do when you see a magic number: +1. Check if an appropriate constant already exists in `DesignConstants.swift` +2. If not, add a new constant with a semantic name +3. Use the constant in place of the raw value +4. If it's truly view-specific and used only once, extract to a `private let` at the top of the view struct + +### Examples of violations: +```swift +// ❌ BAD - Magic numbers everywhere +.padding(16) +.opacity(0.6) +.frame(width: 80, height: 52) +.shadow(radius: 10, y: 5) +Color(red: 0.25, green: 0.3, blue: 0.45) + +// ✅ GOOD - Named constants +.padding(Design.Spacing.large) +.opacity(Design.Opacity.accent) +.frame(width: Design.Size.bonusZoneWidth, height: Design.Size.topBetRowHeight) +.shadow(radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge) +Color.BettingZone.dragonBonusLight +``` + + +## Design constants instructions + +- Create a centralized design constants file (e.g., `DesignConstants.swift`) using enums for namespacing: + ```swift + enum Design { + enum Spacing { + static let xxSmall: CGFloat = 2 + static let xSmall: CGFloat = 4 + static let small: CGFloat = 8 + static let medium: CGFloat = 12 + static let large: CGFloat = 16 + static let xLarge: CGFloat = 20 + } + enum CornerRadius { + static let small: CGFloat = 8 + static let medium: CGFloat = 12 + static let large: CGFloat = 16 + } + enum BaseFontSize { + static let small: CGFloat = 10 + static let body: CGFloat = 14 + static let large: CGFloat = 18 + static let title: CGFloat = 24 + } + enum Opacity { + static let subtle: Double = 0.1 + static let hint: Double = 0.2 + static let light: Double = 0.3 + static let medium: Double = 0.5 + static let accent: Double = 0.6 + static let strong: Double = 0.7 + static let heavy: Double = 0.8 + static let almostFull: Double = 0.9 + } + enum LineWidth { + static let thin: CGFloat = 1 + static let medium: CGFloat = 2 + static let thick: CGFloat = 3 + } + enum Shadow { + static let radiusSmall: CGFloat = 2 + static let radiusMedium: CGFloat = 6 + static let radiusLarge: CGFloat = 10 + static let offsetSmall: CGFloat = 1 + static let offsetMedium: CGFloat = 3 + } + enum Animation { + static let quick: Double = 0.3 + static let springDuration: Double = 0.4 + static let staggerDelay1: Double = 0.1 + static let staggerDelay2: Double = 0.25 + } + } + ``` +- For colors used across the app, extend `Color` with semantic color definitions: + ```swift + extension Color { + enum Primary { + static let background = Color(red: 0.1, green: 0.2, blue: 0.3) + static let accent = Color(red: 0.8, green: 0.6, blue: 0.2) + } + enum Button { + static let goldLight = Color(red: 1.0, green: 0.85, blue: 0.3) + static let goldDark = Color(red: 0.9, green: 0.7, blue: 0.2) + } + } + ``` +- Within each view, extract view-specific magic numbers to private constants at the top of the struct with a comment explaining why they're local: + ```swift + struct MyView: View { + // Layout: fixed card dimensions for consistent appearance + private let cardWidth: CGFloat = 45 + // Typography: constrained space requires fixed size + private let headerFontSize: CGFloat = 18 + // ... + } + ``` +- Reference design constants in views: `Design.Spacing.medium`, `Design.CornerRadius.large`, `Color.Primary.accent`. +- Keep design constants organized by category: Spacing, CornerRadius, BaseFontSize, IconSize, Size, Animation, Opacity, LineWidth, Shadow. +- When adding new features, check existing constants first before creating new ones. +- Name constants semantically (what they represent) not literally (their value): `accent` not `pointSix`, `large` not `sixteen`. + + +## Dynamic Type instructions + +- Always support Dynamic Type for accessibility; never use fixed font sizes without scaling. +- Use `@ScaledMetric` to scale custom font sizes and dimensions based on user accessibility settings: + ```swift + struct MyView: View { + @ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = 14 + @ScaledMetric(relativeTo: .title) private var titleFontSize: CGFloat = 24 + @ScaledMetric(relativeTo: .caption) private var chipTextSize: CGFloat = 11 + + var body: some View { + Text("Hello") + .font(.system(size: bodyFontSize, weight: .medium)) + } + } + ``` +- Choose the appropriate `relativeTo` text style based on the semantic purpose: + - `.largeTitle`, `.title`, `.title2`, `.title3` for headings + - `.headline`, `.subheadline` for emphasized content + - `.body` for main content + - `.callout`, `.footnote`, `.caption`, `.caption2` for smaller text +- For constrained UI elements (chips, cards, badges) where overflow would break the design, you may use fixed sizes but document the reason: + ```swift + // Fixed size: chip face has strict space constraints + private let chipValueFontSize: CGFloat = 11 + ``` +- Prefer system text styles when possible: `.font(.body)`, `.font(.title)`, `.font(.caption)`. +- Test with accessibility settings: Settings > Accessibility > Display & Text Size > Larger Text. + + +## VoiceOver accessibility instructions + +- All interactive elements (buttons, betting zones, selectable items) must have meaningful `.accessibilityLabel()`. +- Use `.accessibilityValue()` to communicate dynamic state (e.g., current bet amount, selection state, hand value). +- Use `.accessibilityHint()` to describe what will happen when interacting with an element: + ```swift + Button("Deal", action: deal) + .accessibilityHint("Deals cards and starts the round") + ``` +- Use `.accessibilityAddTraits()` to communicate element type: + - `.isButton` for tappable elements that aren't SwiftUI Buttons + - `.isHeader` for section headers + - `.isModal` for modal overlays + - `.updatesFrequently` for live-updating content +- Hide purely decorative elements from VoiceOver: + ```swift + TableBackgroundView() + .accessibilityHidden(true) // Decorative element + ``` +- Group related elements to reduce VoiceOver navigation complexity: + ```swift + VStack { + handLabel + cardStack + valueDisplay + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Player hand") + .accessibilityValue("Ace of Hearts, King of Spades. Value: 1") + ``` +- For complex elements, use `.accessibilityElement(children: .contain)` to allow navigation to children while adding context. +- Post accessibility announcements for important events: + ```swift + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(500)) + UIAccessibility.post(notification: .announcement, argument: "Player wins!") + } + ``` +- Provide accessibility names for model types that appear in UI: + ```swift + enum Suit { + var accessibilityName: String { + switch self { + case .hearts: return String(localized: "Hearts") + // ... + } + } + } + ``` +- Test with VoiceOver enabled: Settings > Accessibility > VoiceOver. + + +## Project structure + +- Use a consistent project structure, with folder layout determined by app features. +- Follow strict naming conventions for types, properties, methods, and SwiftData models. +- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file. +- Write unit tests for core application logic. +- Only write UI tests if unit tests are not possible. +- Add code comments and documentation comments as needed. +- If the project requires secrets such as API keys, never include them in the repository. + + +## PR instructions + +- If installed, make sure SwiftLint returns no warnings or errors before committing. + diff --git a/Blackjack/Assets.xcassets/AccentColor.colorset/Contents.json b/Blackjack/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Blackjack/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Blackjack/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/Blackjack/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..86801ee Binary files /dev/null and b/Blackjack/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/Blackjack/Assets.xcassets/AppIcon.appiconset/Contents.json b/Blackjack/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a0146d3 --- /dev/null +++ b/Blackjack/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Blackjack/Assets.xcassets/Contents.json b/Blackjack/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Blackjack/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Blackjack/Blackjack.entitlements b/Blackjack/Blackjack.entitlements new file mode 100644 index 0000000..c280ba7 --- /dev/null +++ b/Blackjack/Blackjack.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.icloud-container-identifiers + + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) + + diff --git a/Blackjack/BlackjackApp.swift b/Blackjack/BlackjackApp.swift new file mode 100644 index 0000000..72d00d3 --- /dev/null +++ b/Blackjack/BlackjackApp.swift @@ -0,0 +1,29 @@ +// +// BlackjackApp.swift +// Blackjack +// +// Main application entry point. +// + +import SwiftUI +import CasinoKit + +@main +struct BlackjackApp: App { + init() { + // Configure sound manager defaults + SoundManager.shared.soundEnabled = true + SoundManager.shared.hapticsEnabled = true + } + + var body: some Scene { + WindowGroup { + // #if DEBUG + // IconGeneratorView() + // #else + ContentView() // your real app root + // #endif + } + } +} + diff --git a/Blackjack/ContentView.swift b/Blackjack/ContentView.swift new file mode 100644 index 0000000..2551eb1 --- /dev/null +++ b/Blackjack/ContentView.swift @@ -0,0 +1,18 @@ +// +// ContentView.swift +// Blackjack +// +// Root view for the Blackjack app. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + GameTableView() + } +} + +#Preview { + ContentView() +} diff --git a/Blackjack/Engine/BlackjackEngine.swift b/Blackjack/Engine/BlackjackEngine.swift new file mode 100644 index 0000000..a996279 --- /dev/null +++ b/Blackjack/Engine/BlackjackEngine.swift @@ -0,0 +1,247 @@ +// +// BlackjackEngine.swift +// Blackjack +// +// Core game logic for Blackjack. +// + +import Foundation +import CasinoKit + +/// Manages the Blackjack game rules and shoe. +@Observable +@MainActor +final class BlackjackEngine { + // MARK: - Properties + + /// The card shoe. + private(set) var shoe: Deck + + /// Number of decks in the shoe. + let deckCount: Int + + /// Settings reference for rule variations. + private let settings: GameSettings + + /// Cards remaining in shoe. + var cardsRemaining: Int { + shoe.cardsRemaining + } + + /// Whether the shoe needs reshuffling (below 25% remaining). + var needsReshuffle: Bool { + let threshold = (52 * deckCount) / 4 + return cardsRemaining < threshold + } + + // MARK: - Initialization + + init(settings: GameSettings) { + self.settings = settings + self.deckCount = settings.deckCount.rawValue + self.shoe = Deck(deckCount: deckCount) + shoe.shuffle() + } + + // MARK: - Shoe Management + + /// Reshuffles the shoe. + func reshuffle() { + shoe = Deck(deckCount: deckCount) + shoe.shuffle() + } + + /// Deals a single card from the shoe. + func dealCard() -> Card? { + shoe.draw() + } + + // MARK: - Hand Evaluation + + /// Determines if dealer should hit based on rules. + func dealerShouldHit(hand: BlackjackHand) -> Bool { + let value = hand.value + + if value < 17 { + return true + } + + if value == 17 && hand.isSoft && settings.dealerHitsSoft17 { + return true + } + + return false + } + + /// Determines the result of a player hand against dealer. + func determineResult(playerHand: BlackjackHand, dealerHand: BlackjackHand) -> HandResult { + let playerValue = playerHand.value + let dealerValue = dealerHand.value + + // Player busted + if playerHand.isBusted { + return .bust + } + + // Player has blackjack + if playerHand.isBlackjack { + if dealerHand.isBlackjack { + return .push + } + return .blackjack + } + + // Dealer busted + if dealerHand.isBusted { + return .win + } + + // Dealer has blackjack (player doesn't) + if dealerHand.isBlackjack { + return .lose + } + + // Compare values + if playerValue > dealerValue { + return .win + } else if playerValue < dealerValue { + return .lose + } else { + return .push + } + } + + /// Calculates payout for a hand result. + func calculatePayout(bet: Int, result: HandResult, isDoubled: Bool) -> Int { + let effectiveBet = isDoubled ? bet * 2 : bet + + switch result { + case .blackjack: + return Int(Double(bet) * settings.blackjackPayout) + bet + case .win: + return effectiveBet * 2 + case .push: + return effectiveBet + case .lose, .bust: + return 0 + case .surrender: + return bet / 2 + case .insuranceWin: + return bet * 3 // 2:1 + original bet + case .insuranceLose: + return 0 + } + } + + // MARK: - Action Availability + + /// Whether player can double down on this hand. + func canDoubleDown(hand: BlackjackHand, balance: Int) -> Bool { + guard hand.cards.count == 2 else { return false } + guard !hand.isDoubledDown else { return false } + guard balance >= hand.bet else { return false } + + // After split, check DAS rule + if hand.isSplit && !settings.doubleAfterSplit { + return false + } + + return true + } + + /// Whether player can split this hand. + func canSplit(hand: BlackjackHand, balance: Int, currentSplitCount: Int) -> Bool { + guard hand.canSplit else { return false } + guard balance >= hand.bet else { return false } + guard currentSplitCount < 3 else { return false } // Max 4 hands + + // Check resplit aces + if hand.isSplit && hand.cards.first?.rank == .ace && !settings.resplitAces { + return false + } + + return true + } + + /// Whether player can surrender. + func canSurrender(hand: BlackjackHand) -> Bool { + guard settings.lateSurrender else { return false } + guard hand.cards.count == 2 else { return false } + guard !hand.isSplit else { return false } + return true + } + + /// Whether insurance should be offered. + func shouldOfferInsurance(dealerUpCard: Card) -> Bool { + settings.insuranceAllowed && dealerUpCard.rank == .ace + } + + // MARK: - Basic Strategy Hint + + /// Returns the basic strategy recommendation. + func getHint(playerHand: BlackjackHand, dealerUpCard: Card) -> String { + let playerValue = playerHand.value + let dealerValue = dealerUpCard.blackjackValue + let isSoft = playerHand.isSoft + let canDouble = playerHand.cards.count == 2 + + // Pairs + if playerHand.canSplit { + let pairRank = playerHand.cards[0].rank + switch pairRank { + case .ace, .eight: + return String(localized: "Split") + case .ten, .jack, .queen, .king: + return String(localized: "Stand") + case .five: + return canDouble ? String(localized: "Double") : String(localized: "Hit") + case .four: + return (dealerValue == 5 || dealerValue == 6) ? String(localized: "Split") : String(localized: "Hit") + case .two, .three, .seven: + return dealerValue <= 7 ? String(localized: "Split") : String(localized: "Hit") + case .six: + return dealerValue <= 6 ? String(localized: "Split") : String(localized: "Hit") + case .nine: + return (dealerValue == 7 || dealerValue >= 10) ? String(localized: "Stand") : String(localized: "Split") + } + } + + // Soft hands + if isSoft { + if playerValue >= 19 { + return String(localized: "Stand") + } + if playerValue == 18 { + if dealerValue >= 9 { + return String(localized: "Hit") + } + return String(localized: "Stand") + } + // Soft 17 or less + return String(localized: "Hit") + } + + // Hard hands + if playerValue >= 17 { + return String(localized: "Stand") + } + if playerValue >= 13 && dealerValue <= 6 { + return String(localized: "Stand") + } + if playerValue == 12 && dealerValue >= 4 && dealerValue <= 6 { + return String(localized: "Stand") + } + if playerValue == 11 && canDouble { + return String(localized: "Double") + } + if playerValue == 10 && dealerValue <= 9 && canDouble { + return String(localized: "Double") + } + if playerValue == 9 && dealerValue >= 3 && dealerValue <= 6 && canDouble { + return String(localized: "Double") + } + + return String(localized: "Hit") + } +} + diff --git a/Blackjack/Engine/GameState.swift b/Blackjack/Engine/GameState.swift new file mode 100644 index 0000000..3f18f9f --- /dev/null +++ b/Blackjack/Engine/GameState.swift @@ -0,0 +1,622 @@ +// +// GameState.swift +// Blackjack +// +// Manages the game state machine for Blackjack. +// + +import SwiftUI +import CasinoKit + +/// Current phase of the game. +enum GamePhase: Equatable { + case betting + case dealing + case insurance + case playerTurn(handIndex: Int) + case dealerTurn + case roundComplete +} + +/// Main game state manager. +@Observable +@MainActor +final class GameState { + // MARK: - Core State + + /// Current player balance. + private(set) var balance: Int + + /// Current game phase. + private(set) var currentPhase: GamePhase = .betting + + /// The current bet amount (before deal). + var currentBet: Int = 0 + + /// Insurance bet amount. + var insuranceBet: Int = 0 + + // MARK: - Hands + + /// Player's hands (can have multiple after splits). + private(set) var playerHands: [BlackjackHand] = [] + + /// Dealer's hand. + private(set) var dealerHand: BlackjackHand = BlackjackHand() + + /// Index of the hand currently being played. + private(set) var activeHandIndex: Int = 0 + + /// The active player hand. + var activeHand: BlackjackHand? { + guard activeHandIndex < playerHands.count else { return nil } + return playerHands[activeHandIndex] + } + + /// Dealer's face-up card. + var dealerUpCard: Card? { + dealerHand.cards.first + } + + // MARK: - UI State + + /// Whether to show the result banner. + var showResultBanner: Bool = false + + /// The result of the last round. + private(set) var lastRoundResult: RoundResult? + + /// Round history for statistics. + private(set) var roundHistory: [RoundResult] = [] + + // MARK: - Statistics (persisted) + + private(set) var totalWinnings: Int = 0 + private(set) var biggestWin: Int = 0 + private(set) var biggestLoss: Int = 0 + private(set) var blackjackCount: Int = 0 + private(set) var bustCount: Int = 0 + + // MARK: - Persistence + + /// iCloud sync manager for game data. + let persistence: CloudSyncManager + + // MARK: - Engine & Settings + + /// The game engine. + let engine: BlackjackEngine + + /// Game settings. + let settings: GameSettings + + /// Sound manager. + private let sound = SoundManager.shared + + // MARK: - Computed Properties + + /// Total bet across all hands. + var totalBet: Int { + playerHands.reduce(0) { $0 + $1.bet * ($1.isDoubledDown ? 2 : 1) } + insuranceBet + } + + /// Whether player can place a bet. + var canBet: Bool { + currentPhase == .betting && currentBet + settings.minBet <= balance + } + + /// Whether the current hand can hit. + var canHit: Bool { + guard case .playerTurn = currentPhase else { return false } + return activeHand?.canHit ?? false + } + + /// Whether the current hand can stand. + var canStand: Bool { + guard case .playerTurn = currentPhase else { return false } + return !(activeHand?.isBusted ?? true) + } + + /// Whether the current hand can double. + var canDouble: Bool { + guard case .playerTurn = currentPhase else { return false } + guard let hand = activeHand else { return false } + return engine.canDoubleDown(hand: hand, balance: balance) + } + + /// Whether the current hand can split. + var canSplit: Bool { + guard case .playerTurn = currentPhase else { return false } + guard let hand = activeHand else { return false } + let splitCount = playerHands.count - 1 + return engine.canSplit(hand: hand, balance: balance, currentSplitCount: splitCount) + } + + /// Whether the player can surrender. + var canSurrender: Bool { + guard case .playerTurn = currentPhase else { return false } + guard let hand = activeHand else { return false } + return engine.canSurrender(hand: hand) + } + + /// Whether the game is over (out of money). + var isGameOver: Bool { + balance < settings.minBet && currentPhase == .betting + } + + /// Total rounds played. + var roundsPlayed: Int { + roundHistory.count + } + + // MARK: - Initialization + + init(settings: GameSettings) { + self.settings = settings + self.balance = settings.startingBalance + self.engine = BlackjackEngine(settings: settings) + self.persistence = CloudSyncManager() + syncSoundSettings() + loadSavedGame() + } + + /// Syncs sound settings with SoundManager. + private func syncSoundSettings() { + sound.soundEnabled = settings.soundEnabled + sound.hapticsEnabled = settings.hapticsEnabled + sound.volume = settings.soundVolume + } + + // MARK: - Persistence + + /// Loads saved game data from iCloud or local storage. + private func loadSavedGame() { + let data = persistence.load() + self.balance = data.balance + self.totalWinnings = data.totalWinnings + self.biggestWin = data.biggestWin + self.biggestLoss = data.biggestLoss + self.blackjackCount = data.blackjackCount + self.bustCount = data.bustCount + + // Set up callback for when iCloud data arrives later + persistence.onCloudDataReceived = { [weak self] newData in + guard let self else { return } + self.balance = newData.balance + self.totalWinnings = newData.totalWinnings + self.biggestWin = newData.biggestWin + self.biggestLoss = newData.biggestLoss + self.blackjackCount = newData.blackjackCount + self.bustCount = newData.bustCount + } + } + + /// Saves current game data to iCloud and local storage. + private func saveGameData() { + let savedRounds: [SavedRoundResult] = roundHistory.map { result in + SavedRoundResult( + date: Date(), + mainResult: result.mainHandResult.saveName, + hadSplit: result.splitHandResult != nil, + totalWinnings: result.totalWinnings + ) + } + + let data = BlackjackGameData( + lastModified: Date(), + balance: balance, + roundHistory: savedRounds, + totalWinnings: totalWinnings, + biggestWin: biggestWin, + biggestLoss: biggestLoss, + blackjackCount: blackjackCount, + bustCount: bustCount + ) + persistence.save(data) + } + + /// Clears all saved data. + func clearAllData() { + persistence.reset() + balance = settings.startingBalance + totalWinnings = 0 + biggestWin = 0 + biggestLoss = 0 + blackjackCount = 0 + bustCount = 0 + roundHistory = [] + newRound() + } + + // MARK: - Betting + + /// Places a bet. + func placeBet(amount: Int) { + guard canBet else { return } + guard currentBet + amount <= settings.maxBet else { return } + guard balance >= amount else { return } + + currentBet += amount + balance -= amount + sound.play(.chipPlace) + } + + /// Clears the current bet. + func clearBet() { + balance += currentBet + currentBet = 0 + sound.play(.chipPlace) + } + + // MARK: - Dealing + + /// Deals the initial cards. + func deal() async { + guard currentBet >= settings.minBet else { return } + + currentPhase = .dealing + playerHands = [BlackjackHand(bet: currentBet)] + dealerHand = BlackjackHand() + activeHandIndex = 0 + insuranceBet = 0 + + let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0 + + // Deal cards: player, dealer, player, dealer + for i in 0..<4 { + if let card = engine.dealCard() { + if i % 2 == 0 { + playerHands[0].cards.append(card) + } else { + dealerHand.cards.append(card) + } + sound.play(.cardDeal) + if delay > 0 { + try? await Task.sleep(for: .seconds(delay)) + } + } + } + + // Check for insurance offer + if let upCard = dealerUpCard, engine.shouldOfferInsurance(dealerUpCard: upCard) { + currentPhase = .insurance + return + } + + // Check for immediate blackjacks + await checkForBlackjacks() + } + + /// Checks for blackjacks and handles accordingly. + private func checkForBlackjacks() async { + let playerBJ = playerHands[0].isBlackjack + let dealerBJ = dealerHand.isBlackjack + + if playerBJ || dealerBJ { + // Reveal dealer card + sound.play(.cardFlip) + + if playerBJ && dealerBJ { + // Push + playerHands[0].result = .push + await completeRound() + } else if playerBJ { + // Player wins + playerHands[0].result = .blackjack + await completeRound() + } else { + // Dealer wins + playerHands[0].result = .lose + await completeRound() + } + } else { + currentPhase = .playerTurn(handIndex: 0) + } + } + + // MARK: - Insurance + + /// Takes insurance bet. + func takeInsurance() async { + let insuranceAmount = currentBet / 2 + guard balance >= insuranceAmount else { + declineInsurance() + return + } + + insuranceBet = insuranceAmount + balance -= insuranceAmount + sound.play(.chipPlace) + + // Check dealer blackjack + if dealerHand.isBlackjack { + sound.play(.cardFlip) + // Insurance wins + let payout = insuranceBet * 3 + balance += payout + playerHands[0].result = .lose + await completeRound() + } else { + // Insurance loses, continue game + insuranceBet = 0 // Lost the insurance bet + await checkForBlackjacks() + } + } + + /// Declines insurance. + func declineInsurance() { + Task { + await checkForBlackjacks() + } + } + + // MARK: - Player Actions + + /// Player hits (takes another card). + func hit() async { + guard canHit else { return } + guard let card = engine.dealCard() else { return } + + playerHands[activeHandIndex].cards.append(card) + sound.play(.cardDeal) + + // Check for bust or 21 + if playerHands[activeHandIndex].isBusted { + playerHands[activeHandIndex].result = .bust + await moveToNextHand() + } else if playerHands[activeHandIndex].value == 21 { + playerHands[activeHandIndex].isStanding = true + await moveToNextHand() + } + } + + /// Player stands. + func stand() async { + guard canStand else { return } + + playerHands[activeHandIndex].isStanding = true + await moveToNextHand() + } + + /// Player doubles down. + func doubleDown() async { + guard canDouble else { return } + + let additionalBet = playerHands[activeHandIndex].bet + balance -= additionalBet + playerHands[activeHandIndex].isDoubledDown = true + sound.play(.chipPlace) + + // Deal one card and stand + if let card = engine.dealCard() { + playerHands[activeHandIndex].cards.append(card) + sound.play(.cardDeal) + } + + if playerHands[activeHandIndex].isBusted { + playerHands[activeHandIndex].result = .bust + } else { + playerHands[activeHandIndex].isStanding = true + } + + await moveToNextHand() + } + + /// Player splits the hand. + func split() async { + guard canSplit else { return } + + let originalHand = playerHands[activeHandIndex] + let splitCard = originalHand.cards[1] + + // Create two new hands + var hand1 = BlackjackHand(cards: [originalHand.cards[0]], bet: originalHand.bet) + hand1.isSplit = true + + var hand2 = BlackjackHand(cards: [splitCard], bet: originalHand.bet) + hand2.isSplit = true + + // Deduct bet for second hand + balance -= originalHand.bet + sound.play(.chipPlace) + + // Deal one card to each hand + if let card1 = engine.dealCard() { + hand1.cards.append(card1) + sound.play(.cardDeal) + } + + if let card2 = engine.dealCard() { + hand2.cards.append(card2) + sound.play(.cardDeal) + } + + // Replace original with split hands + playerHands.remove(at: activeHandIndex) + playerHands.insert(hand1, at: activeHandIndex) + playerHands.insert(hand2, at: activeHandIndex + 1) + + // If split aces, typically only one card each and stand + if originalHand.cards[0].rank == .ace && !settings.resplitAces { + playerHands[activeHandIndex].isStanding = true + playerHands[activeHandIndex + 1].isStanding = true + await moveToNextHand() + } else { + currentPhase = .playerTurn(handIndex: activeHandIndex) + } + } + + /// Player surrenders. + func surrender() async { + guard canSurrender else { return } + + playerHands[activeHandIndex].result = .surrender + await completeRound() + } + + // MARK: - Hand Progression + + /// Moves to the next hand or dealer turn. + private func moveToNextHand() async { + // Check if there are more hands to play + let nextIndex = activeHandIndex + 1 + if nextIndex < playerHands.count { + if !playerHands[nextIndex].isStanding && !playerHands[nextIndex].isBusted { + activeHandIndex = nextIndex + currentPhase = .playerTurn(handIndex: nextIndex) + return + } + } + + // Check if all hands are busted + let allBusted = playerHands.allSatisfy { $0.isBusted } + if allBusted { + await completeRound() + return + } + + // Dealer's turn + await dealerTurn() + } + + // MARK: - Dealer Turn + + /// Plays out the dealer's hand. + private func dealerTurn() async { + currentPhase = .dealerTurn + + // Reveal hole card + sound.play(.cardFlip) + + let delay = settings.showAnimations ? 0.5 * settings.dealingSpeed : 0 + if delay > 0 { + try? await Task.sleep(for: .seconds(delay)) + } + + // Dealer draws + while engine.dealerShouldHit(hand: dealerHand) { + if let card = engine.dealCard() { + dealerHand.cards.append(card) + sound.play(.cardDeal) + + if delay > 0 { + try? await Task.sleep(for: .seconds(delay)) + } + } + } + + await completeRound() + } + + // MARK: - Round Completion + + /// Completes the round and calculates payouts. + private func completeRound() async { + currentPhase = .roundComplete + + var roundWinnings = 0 + var wasBlackjack = false + var hadBust = false + + // Evaluate each hand + for i in 0.. biggestWin { + biggestWin = roundWinnings + } + if roundWinnings < biggestLoss { + biggestLoss = roundWinnings + } + if wasBlackjack { + blackjackCount += 1 + } + if hadBust { + bustCount += 1 + } + + // Create round result + lastRoundResult = RoundResult( + mainHandResult: playerHands[0].result ?? .lose, + splitHandResult: playerHands.count > 1 ? playerHands[1].result : nil, + insuranceResult: insuranceBet > 0 ? (dealerHand.isBlackjack ? .insuranceWin : .insuranceLose) : nil, + totalWinnings: roundWinnings, + wasBlackjack: wasBlackjack + ) + + roundHistory.append(lastRoundResult!) + + // Save game data to iCloud + saveGameData() + + // Play appropriate sound + if roundWinnings > 0 { + sound.play(.win) + } else if roundWinnings < 0 { + sound.play(.lose) + } else { + sound.play(.push) + } + + // Reset bet for next round + currentBet = 0 + + showResultBanner = true + + // Check if shoe needs reshuffling + if engine.needsReshuffle { + engine.reshuffle() + } + } + + // MARK: - New Round + + /// Starts a new round. + func newRound() { + playerHands = [] + dealerHand = BlackjackHand() + activeHandIndex = 0 + insuranceBet = 0 + showResultBanner = false + lastRoundResult = nil + currentPhase = .betting + sound.play(.newRound) + } + + // MARK: - Game Reset + + /// Resets the entire game (keeps statistics). + func resetGame() { + balance = settings.startingBalance + roundHistory = [] + engine.reshuffle() + newRound() + saveGameData() + } +} + diff --git a/Blackjack/GAME_TEMPLATE.md b/Blackjack/GAME_TEMPLATE.md new file mode 100644 index 0000000..ff30fcf --- /dev/null +++ b/Blackjack/GAME_TEMPLATE.md @@ -0,0 +1,661 @@ +# Casino Game Development Guide + +This guide explains how to build a new casino card game (like Blackjack, Poker, etc.) using CasinoKit, following the patterns established in the Baccarat app. + +## Project Structure + +``` +YourGame/ +├── YourGameApp.swift # App entry point +├── ContentView.swift # Root view (usually just GameTableView) +├── Engine/ +│ ├── YourGameEngine.swift # Game rules & logic +│ └── GameState.swift # Game state machine +├── Models/ +│ ├── BetType.swift # Game-specific bet types +│ ├── GameResult.swift # Win/loss/push outcomes +│ ├── GameSettings.swift # User settings (can reuse pattern) +│ ├── Hand.swift # Hand representation (if needed) +│ └── Shoe.swift # Card shoe (if multi-deck) +├── Storage/ +│ └── YourGameData.swift # Persistence model (PersistableGameData) +├── Theme/ +│ └── DesignConstants.swift # Game-specific design tokens +├── Views/ +│ ├── GameTableView.swift # Main game screen +│ ├── YourTableLayoutView.swift # Game-specific table layout +│ ├── ResultBannerView.swift # Win/loss display +│ ├── RulesHelpView.swift # Game rules explanation +│ ├── SettingsView.swift # Settings screen +│ └── StatisticsSheetView.swift # Stats display +└── Resources/ + └── Localizable.xcstrings # Translations +``` + +## What CasinoKit Provides + +### Core Components (Import `CasinoKit`) + +| Category | Components | Usage | +|----------|------------|-------| +| **Cards** | `Card`, `Suit`, `Rank`, `Deck` | Card models | +| **Card Views** | `CardView`, `CardPlaceholderView` | Card display | +| **Chips** | `ChipDenomination`, `ChipView`, `ChipStackView`, `ChipSelectorView` | Betting chips | +| **Table** | `TableBackgroundView`, `FeltPatternView` | Casino felt background | +| **Overlays** | `GameOverView`, `ConfettiView` | Game over & celebrations | +| **Top Bar** | `TopBarView` | Balance, settings, stats buttons | +| **Badges** | `ValueBadge` | Numeric value display | +| **Settings** | `SettingsToggle`, `SpeedPicker`, `VolumePicker`, `BalancePicker` | Settings UI | +| **Sheets** | `SheetContainerView`, `SheetSection` | Modal sheets | +| **Branding** | `AppIconView`, `LaunchScreenView` | App icons & splash | +| **Audio** | `SoundManager`, `GameSound` | Sound effects & haptics | +| **Storage** | `CloudSyncManager`, `PersistableGameData` | iCloud persistence | +| **Models** | `TableLimits` | Betting limits presets | +| **Design** | `CasinoDesign` | Spacing, colors, animations | + +### Using CasinoKit Components + +```swift +import SwiftUI +import CasinoKit + +struct GameTableView: View { + @State private var settings = GameSettings() + @State private var gameState: GameState? + @State private var selectedChip: ChipDenomination = .hundred + + var body: some View { + ZStack { + // 1. Table Background (from CasinoKit) + TableBackgroundView() + + VStack { + // 2. Top Bar (from CasinoKit) + TopBarView( + balance: state.balance, + secondaryInfo: "\(state.engine.shoe.cardsRemaining)", + secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill", + onReset: { state.resetGame() }, + onSettings: { showSettings = true }, + onHelp: { showRules = true }, + onStats: { showStats = true } + ) + + // 3. Your Game-Specific Table Layout + YourTableLayoutView(...) + + // 4. Chip Selector (from CasinoKit) + ChipSelectorView( + selectedChip: $selectedChip, + availableChips: ChipDenomination.allCases + ) + + // 5. Action Buttons (game-specific) + ActionButtonsView(...) + } + + // 6. Result Banner (game-specific, but follows pattern) + if state.showResultBanner { + ResultBannerView(...) + } + + // 7. Confetti for Wins (from CasinoKit) + if state.lastWinnings > 0 { + ConfettiView() + } + + // 8. Game Over (from CasinoKit) + if state.isGameOver { + GameOverView( + roundsPlayed: state.roundsPlayed, + onPlayAgain: { state.resetGame() } + ) + } + } + .sheet(isPresented: $showSettings) { + SettingsView(settings: settings, gameState: state) { ... } + } + } +} +``` + +## Game-Specific Implementation + +### 1. Game Engine (Required) + +Create your game's rule engine. This handles: +- Card dealing logic +- Hand evaluation +- Win/loss determination +- Payout calculations + +```swift +// Engine/YourGameEngine.swift +import CasinoKit + +@Observable +@MainActor +final class YourGameEngine { + var shoe: Shoe + var playerHand: [Card] = [] + var dealerHand: [Card] = [] + + init(deckCount: Int = 6) { + self.shoe = Shoe(deckCount: deckCount) + } + + func dealInitialCards() { ... } + func evaluateHand(_ cards: [Card]) -> Int { ... } + func determineWinner() -> GameResult { ... } + func calculatePayout(bet: Int, result: GameResult) -> Int { ... } +} +``` + +### 2. Game State (Required) + +Manages the state machine for your game: + +```swift +// Engine/GameState.swift +import SwiftUI +import CasinoKit + +enum GamePhase { + case betting + case dealing + case playerTurn // Blackjack-specific + case dealerTurn // Blackjack-specific + case roundComplete +} + +@Observable +@MainActor +final class GameState { + // Core state + var balance: Int + var currentBets: [BetType: Int] = [:] + var currentPhase: GamePhase = .betting + var showResultBanner = false + + // Engine + let engine: YourGameEngine + + // Persistence + private let persistence: CloudSyncManager + + // Sound + private let sound = SoundManager.shared + + init(settings: GameSettings) { + self.engine = YourGameEngine(deckCount: settings.deckCount.rawValue) + self.balance = settings.startingBalance + self.persistence = CloudSyncManager() + loadSavedGame() + } + + func placeBet(type: BetType, amount: Int) { + currentBets[type, default: 0] += amount + balance -= amount + sound.play(.chipPlace) + } + + func deal() async { + currentPhase = .dealing + sound.play(.cardDeal) + // Game-specific dealing logic + } + + func newRound() { + currentPhase = .betting + showResultBanner = false + sound.play(.newRound) + } +} +``` + +### 3. Bet Types (Game-Specific) + +```swift +// Models/BetType.swift + +enum BetType: String, CaseIterable, Identifiable { + // Blackjack example: + case main = "main" + case insurance = "insurance" + case doubleDown = "double" + case split = "split" + + // Baccarat example: + // case player, banker, tie, playerPair, bankerPair, dragonBonusPlayer, dragonBonusBanker + + var id: String { rawValue } + + var displayName: String { ... } + var payoutMultiplier: Double { ... } +} +``` + +### 4. Game Result (Game-Specific) + +```swift +// Models/GameResult.swift + +enum GameResult: Equatable { + // Blackjack example: + case playerWins + case dealerWins + case push + case blackjack + case bust + + var displayText: String { ... } + var color: Color { ... } +} +``` + +### 5. Table Layout (Game-Specific) + +This is the main visual difference between games: + +```swift +// Views/YourTableLayoutView.swift + +struct BlackjackTableView: View { + // Shows dealer hand at top, player hand(s) below + // Hit/Stand/Double/Split buttons + // Insurance betting zone +} + +struct BaccaratTableView: View { + // Shows Player/Banker/Tie betting zones + // Side bet zones (pairs, dragon bonus) +} + +struct PokerTableView: View { + // Community cards in center + // Player positions around table + // Pot display +} +``` + +### 6. Settings View (Mostly Reusable) + +```swift +// Views/SettingsView.swift +import CasinoKit + +struct SettingsView: View { + @Bindable var settings: GameSettings + let gameState: GameState + + var body: some View { + SheetContainerView(title: "Settings") { + // Table Limits (from CasinoKit pattern) + SheetSection(title: "TABLE LIMITS", icon: "banknote") { + // Use TableLimits enum from CasinoKit + } + + // Deck Settings (game-specific) + SheetSection(title: "DECK SETTINGS", icon: "rectangle.portrait.on.rectangle.portrait") { + // DeckCount options + } + + // Display Settings (reusable) + SheetSection(title: "DISPLAY", icon: "eye") { + SettingsToggle(title: "...", subtitle: "...", isOn: $settings.showX) + } + + // Sound (from CasinoKit) + SheetSection(title: "SOUND & HAPTICS", icon: "speaker.wave.2") { + SettingsToggle(...) + VolumePicker(volume: $settings.soundVolume) + } + + // iCloud Sync (pattern from Baccarat) + SheetSection(title: "CLOUD SYNC", icon: "icloud") { ... } + } + } +} +``` + +## Sound Integration + +```swift +// In your GameState +let sound = SoundManager.shared + +// Play sounds at appropriate moments: +sound.play(.chipPlace) // When placing a bet +sound.play(.cardDeal) // When dealing cards +sound.play(.cardFlip) // When flipping cards +sound.play(.win) // On player win +sound.play(.lose) // On player loss +sound.play(.push) // On tie/push +sound.play(.newRound) // Starting new round +sound.play(.gameOver) // When out of chips +``` + +## Persistence + +```swift +// Storage/YourGameData.swift +import CasinoKit + +struct BlackjackGameData: PersistableGameData { + static let gameIdentifier = "blackjack" + + var roundsPlayed: Int { roundHistory.count } + var lastModified: Date + + static var empty: BlackjackGameData { + BlackjackGameData( + lastModified: Date(), + balance: 10_000, + roundHistory: [], + totalWinnings: 0, + blackjackCount: 0, // Game-specific stat + bustCount: 0 // Game-specific stat + ) + } + + var balance: Int + var roundHistory: [SavedRoundResult] + var totalWinnings: Int + var blackjackCount: Int + var bustCount: Int +} +``` + +## Design Constants + +Extend CasinoDesign for game-specific values: + +```swift +// Theme/DesignConstants.swift + +enum Design { + // Reuse CasinoDesign values + typealias Spacing = CasinoDesign.Spacing + typealias CornerRadius = CasinoDesign.CornerRadius + typealias Animation = CasinoDesign.Animation + + // Game-specific sizes + enum Size { + static let playerCardWidth: CGFloat = 55 + static let dealerCardWidth: CGFloat = 50 + // ... game-specific dimensions + } + + // Game-specific colors (extend Color) +} + +extension Color { + enum BettingZone { + static let main = Color.blue.opacity(0.3) + static let insurance = Color.yellow.opacity(0.3) + // ... game-specific colors + } +} +``` + +## Localization + +Use String Catalogs (`.xcstrings`): + +```swift +// Game-specific strings +Text(String(localized: "Hit")) +Text(String(localized: "Stand")) +Text(String(localized: "Double Down")) +Text(String(localized: "Split")) +Text(String(localized: "Blackjack!")) +Text(String(localized: "Bust!")) +``` + +## Responsive Layout (iPhone vs iPad) + +### The Problem + +On iPad, content that looks great on iPhone will **stretch to fill the screen**, making: +- Betting areas look awkward and disproportionate +- Cards appear too spread out +- Buttons become oversized +- Overlays cover the entire screen unnecessarily + +### The Solution: Constrained Width Containers + +Use `horizontalSizeClass` to detect iPad and constrain content width: + +```swift +struct GameTableView: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + + /// Whether we're on iPad (regular horizontal size class) + private var isIPad: Bool { + horizontalSizeClass == .regular + } + + /// Maximum content width based on device and orientation + private var maxContentWidth: CGFloat { + if isIPad { + // Landscape on iPad gets more width + return verticalSizeClass == .compact + ? CasinoDesign.Size.maxContentWidthLandscape // 800pt + : CasinoDesign.Size.maxContentWidthPortrait // 500pt + } + return .infinity // iPhone uses full width + } + + var body: some View { + ZStack { + TableBackgroundView() + + VStack { + TopBarView(...) + .frame(maxWidth: maxContentWidth) + + // Game table - constrained on iPad + YourTableLayoutView(...) + .frame(maxWidth: maxContentWidth) + + // Chip selector - constrained + ChipSelectorView(...) + .frame(maxWidth: maxContentWidth) + + // Action buttons - constrained + ActionButtonsView(...) + .frame(maxWidth: maxContentWidth) + } + .frame(maxWidth: .infinity) // Centers constrained content + + // Overlays - full screen background, constrained content + if showResultBanner { + ResultBannerView(...) + } + } + } +} +``` + +### Overlay Pattern (Result Banner, Game Over) + +Overlays need special handling: **full-screen dim background** with **constrained content card**: + +```swift +struct ResultBannerView: View { + var body: some View { + ZStack { + // 1. Full-screen dark background + Color.black.opacity(0.7) + .ignoresSafeArea() + + // 2. Constrained content card + VStack { + // Your content + } + .padding() + .background(RoundedRectangle(cornerRadius: 24).fill(...)) + .frame(maxWidth: CasinoDesign.Size.maxModalWidth) // 450pt + // Centered automatically by ZStack + } + } +} +``` + +### Fixed-Size Containers (Prevent Layout Shifts) + +When content changes (cards dealt, buttons appear/disappear), prevent jarring layout shifts: + +```swift +// ❌ BAD: Container resizes as cards are added +HStack { + ForEach(cards) { card in + CardView(card: card) + } +} + +// ✅ GOOD: Fixed container based on max possible content +ZStack { + // Reserve space for max cards (e.g., 3 cards with overlap) + Color.clear + .frame(width: calculateMaxWidth(), height: cardHeight) + + // Actual cards centered within + HStack(spacing: cardSpacing) { + ForEach(cards) { card in + CardView(card: card) + } + } +} +``` + +Same for buttons: + +```swift +// ✅ GOOD: Fixed height container for buttons +ZStack { + Color.clear + .frame(height: 60) // Fixed height + + // Buttons animate in/out within fixed space + if showDealButton { + ActionButton("Deal", ...) + .transition(.scale.combined(with: .opacity)) + } +} +.animation(.spring(duration: 0.3), value: currentPhase) +``` + +### Confetti Full-Screen Fix + +Confetti must cover the entire screen on iPad: + +```swift +struct ConfettiView: View { + var body: some View { + GeometryReader { geometry in + ZStack { + ForEach(0..<50, id: \.self) { _ in + ConfettiPiece(containerSize: geometry.size) + } + } + } + .ignoresSafeArea() // Critical! + .allowsHitTesting(false) + } +} +``` + +### Design Constants for Responsive Layout + +```swift +// In CasinoDesign.swift +enum Size { + // Max widths for iPad constraint + static let maxContentWidthPortrait: CGFloat = 500 + static let maxContentWidthLandscape: CGFloat = 800 + static let maxModalWidth: CGFloat = 450 +} +``` + +### Common Pitfalls + +| Issue | Symptom | Fix | +|-------|---------|-----| +| Stretched table | Betting zones look huge on iPad | Add `.frame(maxWidth: maxContentWidth)` | +| Overlay too wide | Result banner covers entire iPad screen | Use full-screen bg + constrained content card | +| Layout shifts | Cards/buttons cause content to jump | Use fixed-size `ZStack` containers | +| Confetti cut off | Only shows in center portion | Use `GeometryReader` + `.ignoresSafeArea()` | +| Settings rows cramped | Title/subtitle too close to divider | Add `.padding(.vertical, ...)` | + +### Testing Checklist + +- [ ] iPhone SE (smallest) +- [ ] iPhone Pro Max (largest iPhone) +- [ ] iPad Portrait +- [ ] iPad Landscape +- [ ] iPad Split View +- [ ] Dynamic Type at maximum accessibility size + +## Checklist for New Game + +### Setup +- [ ] Create new target in Xcode +- [ ] Add CasinoKit as dependency +- [ ] Copy `DesignConstants.swift` and customize +- [ ] Create `Localizable.xcstrings` + +### Models +- [ ] Define `BetType` enum +- [ ] Define `GameResult` enum +- [ ] Create `YourGameData` for persistence +- [ ] Create `GameSettings` (or reuse pattern) + +### Engine +- [ ] Implement game rules in `YourGameEngine` +- [ ] Implement `GameState` with phases + +### Views +- [ ] Create `GameTableView` (main container) +- [ ] Create game-specific table layout +- [ ] Create `ResultBannerView` (follow pattern) +- [ ] Create `RulesHelpView` (game rules) +- [ ] Customize `SettingsView` +- [ ] Create `StatisticsSheetView` + +### Integration +- [ ] Wire up `SoundManager` for game events +- [ ] Implement `CloudSyncManager` for persistence +- [ ] Add accessibility labels +- [ ] Add localization for all strings + +### Polish +- [ ] Test Dynamic Type scaling (all sizes including accessibility) +- [ ] Test VoiceOver navigation +- [ ] Create app icon using `AppIconView` + +### Responsive Layout (iPad) +- [ ] Add `maxContentWidth` constraint to main views +- [ ] Test iPad Portrait - content centered, not stretched +- [ ] Test iPad Landscape - wider constraint works well +- [ ] Verify overlays: full-screen bg, constrained content +- [ ] Fixed-size containers prevent layout shifts +- [ ] Confetti covers full screen +- [ ] Settings rows have proper padding + +## What Could Be Added to CasinoKit + +The following patterns from Baccarat could be abstracted: + +1. **Generic ResultBannerView** - Win/loss display with bet breakdown +2. **BettingZone protocol** - Common betting zone behavior +3. **GameStateProtocol** - Common state machine patterns +4. **HandDisplayView** - Generic card hand display +5. **ActionButtonsView** - Deal/Clear/New Round pattern +6. **StatisticsView** - Generic stats display + +--- + +*This guide is based on the Baccarat implementation. For reference, see the Baccarat app structure and CasinoKit source code.* + diff --git a/Blackjack/LaunchScreen.storyboard b/Blackjack/LaunchScreen.storyboard new file mode 100644 index 0000000..e4e6d48 --- /dev/null +++ b/Blackjack/LaunchScreen.storyboard @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Blackjack/Models/BetType.swift b/Blackjack/Models/BetType.swift new file mode 100644 index 0000000..8b31a4a --- /dev/null +++ b/Blackjack/Models/BetType.swift @@ -0,0 +1,41 @@ +// +// BetType.swift +// Blackjack +// +// Available betting options in Blackjack. +// + +import Foundation + +/// Types of bets available in Blackjack. +enum BetType: String, CaseIterable, Identifiable { + case main = "main" + case insurance = "insurance" + case doubleDown = "double" + case split = "split" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .main: return String(localized: "Main Bet") + case .insurance: return String(localized: "Insurance") + case .doubleDown: return String(localized: "Double Down") + case .split: return String(localized: "Split") + } + } + + var payoutMultiplier: Double { + switch self { + case .main: return 1.0 // 1:1 + case .insurance: return 2.0 // 2:1 + case .doubleDown: return 1.0 // 1:1 on doubled bet + case .split: return 1.0 // 1:1 per hand + } + } + + var blackjackPayout: Double { + 1.5 // 3:2 for blackjack + } +} + diff --git a/Blackjack/Models/GameResult.swift b/Blackjack/Models/GameResult.swift new file mode 100644 index 0000000..2bd8f69 --- /dev/null +++ b/Blackjack/Models/GameResult.swift @@ -0,0 +1,75 @@ +// +// GameResult.swift +// Blackjack +// +// Possible outcomes for a Blackjack hand. +// + +import SwiftUI + +/// Result of a single Blackjack hand. +enum HandResult: Equatable { + case blackjack // Natural 21 + case win // Beat dealer + case lose // Lost to dealer or bust + case push // Tie + case bust // Over 21 + case surrender // Gave up half bet + case insuranceWin // Dealer had blackjack + case insuranceLose // Dealer didn't have blackjack + + /// String identifier for persistence. + var saveName: String { + switch self { + case .blackjack: return "blackjack" + case .win: return "win" + case .lose: return "lose" + case .push: return "push" + case .bust: return "bust" + case .surrender: return "surrender" + case .insuranceWin: return "insuranceWin" + case .insuranceLose: return "insuranceLose" + } + } + + var displayText: String { + switch self { + case .blackjack: return String(localized: "BLACKJACK!") + case .win: return String(localized: "WIN!") + case .lose: return String(localized: "LOSE") + case .push: return String(localized: "PUSH") + case .bust: return String(localized: "BUST!") + case .surrender: return String(localized: "SURRENDER") + case .insuranceWin: return String(localized: "INSURANCE WINS") + case .insuranceLose: return String(localized: "INSURANCE LOSES") + } + } + + var color: Color { + switch self { + case .blackjack: return .yellow + case .win: return .green + case .lose, .bust, .insuranceLose: return .red + case .push: return .blue + case .surrender: return .orange + case .insuranceWin: return .green + } + } + + var isWin: Bool { + switch self { + case .blackjack, .win, .insuranceWin: return true + default: return false + } + } +} + +/// Overall game result for the round. +struct RoundResult: Equatable { + let mainHandResult: HandResult + let splitHandResult: HandResult? + let insuranceResult: HandResult? + let totalWinnings: Int + let wasBlackjack: Bool +} + diff --git a/Blackjack/Models/GameSettings.swift b/Blackjack/Models/GameSettings.swift new file mode 100644 index 0000000..5f327e7 --- /dev/null +++ b/Blackjack/Models/GameSettings.swift @@ -0,0 +1,260 @@ +// +// GameSettings.swift +// Blackjack +// +// User-configurable game settings including Blackjack rule variations. +// + +import Foundation +import SwiftUI +import CasinoKit + +/// Blackjack rule variation presets. +enum BlackjackStyle: String, CaseIterable, Identifiable { + case vegas = "vegas" // Vegas Strip rules + case atlantic = "atlantic" // Atlantic City rules + case european = "european" // European no-hole-card + case custom = "custom" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .vegas: return String(localized: "Vegas Strip") + case .atlantic: return String(localized: "Atlantic City") + case .european: return String(localized: "European") + case .custom: return String(localized: "Custom") + } + } + + var description: String { + switch self { + case .vegas: return String(localized: "Dealer stands on soft 17, double after split, 3:2 blackjack") + case .atlantic: return String(localized: "Dealer stands on soft 17, late surrender, 8 decks") + case .european: return String(localized: "No hole card, dealer stands on soft 17, no surrender") + case .custom: return String(localized: "Customize all rules") + } + } +} + +/// Number of decks in the shoe. +enum DeckCount: Int, CaseIterable, Identifiable { + case one = 1 + case two = 2 + case four = 4 + case six = 6 + case eight = 8 + + var id: Int { rawValue } + + var displayName: String { + switch self { + case .one: return "1 Deck" + case .two: return "2 Decks" + case .four: return "4 Decks" + case .six: return "6 Decks" + case .eight: return "8 Decks" + } + } + + var description: String { + switch self { + case .one: return String(localized: "Single deck, higher variance") + case .two: return String(localized: "Lower house edge") + case .four: return String(localized: "Common shoe game") + case .six: return String(localized: "Standard casino") + case .eight: return String(localized: "Maximum penetration") + } + } +} + +/// Observable settings class for Blackjack configuration. +@Observable +@MainActor +final class GameSettings { + // MARK: - Game Style + + /// The preset rule variation. + var gameStyle: BlackjackStyle = .vegas { + didSet { + applyStylePreset() + save() + } + } + + // MARK: - Rule Options + + /// Number of decks in the shoe. + var deckCount: DeckCount = .six { didSet { save() } } + + /// Whether dealer hits on soft 17. + var dealerHitsSoft17: Bool = false { didSet { save() } } + + /// Whether player can double after split. + var doubleAfterSplit: Bool = true { didSet { save() } } + + /// Whether player can re-split aces. + var resplitAces: Bool = false { didSet { save() } } + + /// Whether late surrender is allowed. + var lateSurrender: Bool = true { didSet { save() } } + + /// Whether insurance is offered. + var insuranceAllowed: Bool = true { didSet { save() } } + + /// Blackjack payout ratio (1.5 = 3:2, 1.2 = 6:5) + var blackjackPayout: Double = 1.5 { didSet { save() } } + + // MARK: - Betting Limits + + /// The table limits preset. + var tableLimits: TableLimits = .low { didSet { save() } } + + /// Minimum bet amount. + var minBet: Int { tableLimits.minBet } + + /// Maximum bet amount. + var maxBet: Int { tableLimits.maxBet } + + // MARK: - Starting Balance + + /// The starting balance for new games. + var startingBalance: Int = 10_000 { didSet { save() } } + + // MARK: - Animation Settings + + /// Whether to show dealing animations. + var showAnimations: Bool = true { didSet { save() } } + + /// Speed of card dealing (1.0 = normal) + var dealingSpeed: Double = 1.0 { didSet { save() } } + + // MARK: - Display Settings + + /// Whether to show the cards remaining indicator. + var showCardsRemaining: Bool = true { didSet { save() } } + + /// Whether to show hand history. + var showHistory: Bool = true { didSet { save() } } + + /// Whether to show dealer hints (suggested action). + var showHints: Bool = true { didSet { save() } } + + // MARK: - Sound Settings + + /// Whether sound effects are enabled. + var soundEnabled: Bool = true { didSet { save() } } + + /// Whether haptic feedback is enabled. + var hapticsEnabled: Bool = true { didSet { save() } } + + /// Volume level for sound effects. + var soundVolume: Float = 1.0 { didSet { save() } } + + // MARK: - Initialization + + init() { + self.persistence = CloudSyncManager() + load() + applyStylePreset() + } + + // MARK: - Style Presets + + private func applyStylePreset() { + guard gameStyle != .custom else { return } + + switch gameStyle { + case .vegas: + deckCount = .six + dealerHitsSoft17 = false + doubleAfterSplit = true + resplitAces = false + lateSurrender = false + blackjackPayout = 1.5 + + case .atlantic: + deckCount = .eight + dealerHitsSoft17 = false + doubleAfterSplit = true + resplitAces = true + lateSurrender = true + blackjackPayout = 1.5 + + case .european: + deckCount = .six + dealerHitsSoft17 = false + doubleAfterSplit = true + resplitAces = false + lateSurrender = false + blackjackPayout = 1.5 + + case .custom: + break + } + } + + // MARK: - Persistence + + private let persistence: CloudSyncManager + + var iCloudAvailable: Bool { + FileManager.default.ubiquityIdentityToken != nil + } + + func load() { + let data = persistence.load() + + if let style = BlackjackStyle(rawValue: data.gameStyle) { + self.gameStyle = style + } + if let count = DeckCount(rawValue: data.deckCount) { + self.deckCount = count + } + if let limits = TableLimits(rawValue: data.tableLimits) { + self.tableLimits = limits + } + + self.startingBalance = data.startingBalance + self.dealerHitsSoft17 = data.dealerHitsSoft17 + self.doubleAfterSplit = data.doubleAfterSplit + self.resplitAces = data.resplitAces + self.lateSurrender = data.lateSurrender + self.blackjackPayout = data.blackjackPayout + self.insuranceAllowed = data.insuranceAllowed + self.showAnimations = data.showAnimations + self.dealingSpeed = data.dealingSpeed + self.showCardsRemaining = data.showCardsRemaining + self.showHistory = data.showHistory + self.showHints = data.showHints + self.soundEnabled = data.soundEnabled + self.hapticsEnabled = data.hapticsEnabled + self.soundVolume = data.soundVolume + } + + func save() { + let data = BlackjackSettingsData( + lastModified: Date(), + gameStyle: gameStyle.rawValue, + deckCount: deckCount.rawValue, + tableLimits: tableLimits.rawValue, + startingBalance: startingBalance, + dealerHitsSoft17: dealerHitsSoft17, + doubleAfterSplit: doubleAfterSplit, + resplitAces: resplitAces, + lateSurrender: lateSurrender, + blackjackPayout: blackjackPayout, + insuranceAllowed: insuranceAllowed, + showAnimations: showAnimations, + dealingSpeed: dealingSpeed, + showCardsRemaining: showCardsRemaining, + showHistory: showHistory, + showHints: showHints, + soundEnabled: soundEnabled, + hapticsEnabled: hapticsEnabled, + soundVolume: soundVolume + ) + persistence.save(data) + } +} + diff --git a/Blackjack/Models/Hand.swift b/Blackjack/Models/Hand.swift new file mode 100644 index 0000000..c6e6f3f --- /dev/null +++ b/Blackjack/Models/Hand.swift @@ -0,0 +1,133 @@ +// +// Hand.swift +// Blackjack +// +// Represents a Blackjack hand with value calculation. +// + +import Foundation +import CasinoKit + +/// A hand of cards in Blackjack. +struct BlackjackHand: Identifiable, Equatable { + let id = UUID() + var cards: [Card] + var bet: Int + var isDoubledDown: Bool = false + var isSplit: Bool = false + var isStanding: Bool = false + var result: HandResult? + + init(cards: [Card] = [], bet: Int = 0) { + self.cards = cards + self.bet = bet + } + + /// The best possible value (highest without busting, or lowest if busted). + var value: Int { + let (hard, soft) = calculateValues() + if soft <= 21 { + return soft + } + return hard + } + + /// Whether this hand has a soft value (usable ace). + var isSoft: Bool { + let (hard, soft) = calculateValues() + return soft <= 21 && soft != hard + } + + /// Whether the hand is over 21. + var isBusted: Bool { + value > 21 + } + + /// Whether this is a natural blackjack (two cards totaling 21). + var isBlackjack: Bool { + cards.count == 2 && value == 21 && !isSplit + } + + /// Whether this hand can be split (two cards of same rank). + var canSplit: Bool { + cards.count == 2 && cards[0].rank == cards[1].rank && !isSplit + } + + /// Whether this hand can double down. + var canDoubleDown: Bool { + cards.count == 2 && !isDoubledDown && !isSplit + } + + /// Whether this hand can hit. + var canHit: Bool { + !isBusted && !isStanding && !isBlackjack && cards.count < 5 + } + + /// Calculates both hard and soft values. + private func calculateValues() -> (hard: Int, soft: Int) { + var hardValue = 0 + var aceCount = 0 + + for card in cards { + switch card.rank { + case .ace: + hardValue += 1 + aceCount += 1 + case .two: hardValue += 2 + case .three: hardValue += 3 + case .four: hardValue += 4 + case .five: hardValue += 5 + case .six: hardValue += 6 + case .seven: hardValue += 7 + case .eight: hardValue += 8 + case .nine: hardValue += 9 + case .ten, .jack, .queen, .king: + hardValue += 10 + } + } + + // Calculate soft value (one ace as 11) + var softValue = hardValue + if aceCount > 0 && hardValue + 10 <= 21 { + softValue = hardValue + 10 + } + + return (hardValue, softValue) + } + + /// Display string for the hand value. + var valueDisplay: String { + if isBlackjack { + return "BJ" + } + let (hard, soft) = calculateValues() + if isBusted { + return "\(hard) 💥" + } + if isSoft && soft != hard { + return "\(hard)/\(soft)" + } + return "\(value)" + } +} + +// MARK: - Card Value Extension + +extension Card { + /// The blackjack value of this card (Ace = 1 or 11, face cards = 10). + var blackjackValue: Int { + switch rank { + case .ace: return 1 // Or 11, handled by hand calculation + case .two: return 2 + case .three: return 3 + case .four: return 4 + case .five: return 5 + case .six: return 6 + case .seven: return 7 + case .eight: return 8 + case .nine: return 9 + case .ten, .jack, .queen, .king: return 10 + } + } +} + diff --git a/Blackjack/Resources/Localizable.xcstrings b/Blackjack/Resources/Localizable.xcstrings new file mode 100644 index 0000000..7bc6b98 --- /dev/null +++ b/Blackjack/Resources/Localizable.xcstrings @@ -0,0 +1,2321 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%lld" : { + "comment" : "A label displaying the amount wagered in the current hand. The argument is the amount wagered in the current hand.", + "isCommentAutoGenerated" : true + }, + "%lld." : { + "comment" : "A numbered list item with a callout number and accompanying text. The first argument is the number of the item. The second argument is the text of the item.", + "isCommentAutoGenerated" : true + }, + "%lldpx" : { + "comment" : "A text label displaying the size of the app icon. The argument is the size of the icon in pixels.", + "isCommentAutoGenerated" : true + }, + "•" : { + "comment" : "A bullet point symbol used in lists.", + "isCommentAutoGenerated" : true + }, + "• Add to Assets.xcassets/AppIcon" : { + "comment" : "A bullet point describing how to add an app icon to Xcode's asset catalog.", + "isCommentAutoGenerated" : true + }, + "• Call IconRenderer.renderAppIcon(config: .blackjack)" : { + "comment" : "A bullet point in the \"Option 2: Use IconRenderer in Code\" section of the BrandingPreviewView.", + "isCommentAutoGenerated" : true + }, + "• Run the preview in Xcode" : { + + }, + "• Save the resulting UIImage to files" : { + "comment" : "A step in the process of exporting app icons.", + "isCommentAutoGenerated" : true + }, + "• Screenshot the 1024px icon" : { + + }, + "• Use an online tool to generate all sizes" : { + "comment" : "A step in the process of exporting app icons.", + "isCommentAutoGenerated" : true + }, + "$%lld" : { + "comment" : "A label displaying the current bet amount in the betting zone. The argument is the amount of the current bet.", + "isCommentAutoGenerated" : true + }, + "$%lld bet" : { + "comment" : "An accessibility label and hint for the betting zone button.", + "isCommentAutoGenerated" : true + }, + "2-10: Face value" : { + "comment" : "Description of the card values for cards with values 2 through 10.", + "isCommentAutoGenerated" : true + }, + "A 'soft' hand has an Ace counting as 11." : { + "comment" : "Explanation of how an Ace can be counted as either 1 or 11 in a hand.", + "isCommentAutoGenerated" : true + }, + "Ace: 1 or 11 (whichever helps your hand)" : { + "comment" : "Description of how an Ace can be counted as either 1 or 11 in a blackjack hand.", + "isCommentAutoGenerated" : true + }, + "Actions" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actions" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acciones" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actions" + } + } + } + }, + "After generating:" : { + "comment" : "A heading for instructions on how to use the IconGeneratorView.", + "isCommentAutoGenerated" : true + }, + "All Sizes" : { + "comment" : "A heading that describes the various sizes of the app icon.", + "isCommentAutoGenerated" : true + }, + "Allow doubling on split hands" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow doubling on split hands" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permitir doblar en manos divididas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permettre de doubler sur les mains séparées" + } + } + } + }, + "Allow splitting aces again" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow splitting aces again" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permitir dividir ases nuevamente" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permettre de re-séparer les as" + } + } + } + }, + "Alternative: Use an online tool" : { + "comment" : "A section header that suggests using an online tool to generate app icons.", + "isCommentAutoGenerated" : true + }, + "An Ace + 10-value card dealt initially is 'Blackjack'." : { + "comment" : "Description of a blackjack hand.", + "isCommentAutoGenerated" : true + }, + "App Icon" : { + "comment" : "A label displayed above the preview of the app icon.", + "isCommentAutoGenerated" : true + }, + "App Icon Preview" : { + "comment" : "A title for the preview of the app icon.", + "isCommentAutoGenerated" : true + }, + "Atlantic City" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atlantic City" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atlantic City" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atlantic City" + } + } + } + }, + "Baccarat" : { + "comment" : "The name of a casino game.", + "isCommentAutoGenerated" : true + }, + "Balance" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Balance" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saldo" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solde" + } + } + } + }, + "Basic strategy suggestions" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basic strategy suggestions" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sugerencias de estrategia básica" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suggestions de stratégie de base" + } + } + } + }, + "Beat the dealer by getting a hand value closer to 21 without going over." : { + "comment" : "Text for the objective of the game.", + "isCommentAutoGenerated" : true + }, + "Best" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Best" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mejor" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meilleur" + } + } + } + }, + "BIGGEST SWINGS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "BIGGEST SWINGS" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "MAYORES CAMBIOS" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "PLUS GRANDS ÉCARTS" + } + } + } + }, + "Blackjack" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blackjack" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blackjack" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blackjack" + } + } + } + }, + "BLACKJACK" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "BLACKJACK" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "BLACKJACK" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "BLACKJACK" + } + } + } + }, + "Blackjack pays 3:2 (1.5x your bet)." : { + "comment" : "Description of the payout for blackjack in the Blackjack rules help view.", + "isCommentAutoGenerated" : true + }, + "Blackjack: 3:2" : { + "comment" : "Payout description for a Blackjack win.", + "isCommentAutoGenerated" : true + }, + "BLACKJACK!" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "BLACKJACK!" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡BLACKJACK!" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "BLACKJACK!" + } + } + } + }, + "Blackjacks" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blackjacks" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blackjacks" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blackjacks" + } + } + } + }, + "BUST" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "BUST" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "PASADO" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "SAUTÉ" + } + } + } + }, + "BUST!" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "BUST!" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡PASADO!" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "SAUTÉ!" + } + } + } + }, + "Busts" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Busts" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pasados" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sautés" + } + } + } + }, + "Card dealing animations" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Card dealing animations" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animaciones de reparto" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animations de distribution" + } + } + } + }, + "Card Values" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Card Values" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valores de cartas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valeurs des cartes" + } + } + } + }, + "Cards Remaining" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cards Remaining" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cartas restantes" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cartes restantes" + } + } + } + }, + "Chips, cards, and results" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chips, cards, and results" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fichas, cartas y resultados" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jetons, cartes et résultats" + } + } + } + }, + "Clear" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpiar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer" + } + } + } + }, + "Common shoe game" : { + "comment" : "Description of a deck count option when the user selects 4 decks.", + "isCommentAutoGenerated" : true + }, + "Costs half your original bet." : { + "comment" : "Description of the cost of insurance in the rules help view.", + "isCommentAutoGenerated" : true + }, + "Custom" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personalizado" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personnalisé" + } + } + } + }, + "Customize all rules" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Customize all rules" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personalizar todas las reglas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personnaliser toutes les règles" + } + } + } + }, + "Deal" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deal" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repartir" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distribuer" + } + } + } + }, + "DEALER" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "DEALER" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "CRUPIER" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "CROUPIER" + } + } + } + }, + "Dealer Hits Soft 17" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dealer Hits Soft 17" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crupier pide en 17 suave" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Croupier tire sur 17 souple" + } + } + } + }, + "Dealer must hit on 16 or less." : { + "comment" : "Description of the dealer's rule to hit if the hand value is 16 or less.", + "isCommentAutoGenerated" : true + }, + "Dealer must stand on 17 or more (varies by rules)." : { + "comment" : "Description of the dealer's rule for standing on a hand value of 17 or higher. Note that this text can vary depending on the specific rules of the game being played.", + "isCommentAutoGenerated" : true + }, + "Dealer Rules" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dealer Rules" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reglas del crupier" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Règles du croupier" + } + } + } + }, + "Dealer showing Ace" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dealer showing Ace" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "El crupier muestra un As" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le croupier montre un As" + } + } + } + }, + "Dealer stands on soft 17, double after split, 3:2 blackjack" : { + "comment" : "Description of the \"Vegas Strip\" blackjack rule variation.", + "isCommentAutoGenerated" : true + }, + "Dealer stands on soft 17, late surrender, 8 decks" : { + "comment" : "Description of the \"Atlantic City\" blackjack rule variation.", + "isCommentAutoGenerated" : true + }, + "Dealer: %@. Value: %@" : { + "comment" : "Accessibility label for the dealer's hand in the Blackjack game. The text includes a list of the dealer's visible cards and their total value.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Dealer: %1$@. Value: %2$@" + } + } + } + }, + "Dealer: No cards" : { + "comment" : "Accessibility label for the dealer hand when there are no cards visible.", + "isCommentAutoGenerated" : true + }, + "deck" : { + "comment" : "A unit of measurement for a playing card deck.", + "isCommentAutoGenerated" : true + }, + "DECK SETTINGS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "DECK SETTINGS" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONFIGURACIÓN DE BARAJAS" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "PARAMÈTRES DES JEUX" + } + } + } + }, + "decks" : { + "comment" : "The plural form of \"deck\".", + "isCommentAutoGenerated" : true + }, + "DISPLAY" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "DISPLAY" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "PANTALLA" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "AFFICHAGE" + } + } + } + }, + "Done" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listo" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminé" + } + } + } + }, + "Double" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doblar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doubler" + } + } + } + }, + "Double After Split" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double After Split" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doblar después de dividir" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doubler après séparation" + } + } + } + }, + "Double Down" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double Down" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doblar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doubler" + } + } + } + }, + "Double Down: Double your bet, take one card, then stand" : { + "comment" : "Action available in Blackjack when the player wants to double their bet, take one more card, and then stand.", + "isCommentAutoGenerated" : true + }, + "Double tap to add chips" : { + "comment" : "A hint that appears when a user taps on the betting zone, instructing them to double-tap to add chips.", + "isCommentAutoGenerated" : true + }, + "European" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "European" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Europeo" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Européen" + } + } + } + }, + "GAME STYLE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "GAME STYLE" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "ESTILO DE JUEGO" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "STYLE DE JEU" + } + } + } + }, + "Generally not recommended by basic strategy." : { + "comment" : "Note about the insurance strategy, not directly related to the content of the rule page.", + "isCommentAutoGenerated" : true + }, + "Generate & Save Icons" : { + "comment" : "A button label that triggers icon generation and saving.", + "isCommentAutoGenerated" : true + }, + "Generated Icons:" : { + "comment" : "A label describing the list of icons that have been successfully generated.", + "isCommentAutoGenerated" : true + }, + "Generating..." : { + "comment" : "A label indicating that the app is currently generating icons.", + "isCommentAutoGenerated" : true + }, + "H17 rule, increases house edge" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "H17 rule, increases house edge" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regla H17, aumenta ventaja de la casa" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Règle H17, augmente l'avantage de la maison" + } + } + } + }, + "Hand %lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hand %lld" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mano %lld" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Main %lld" + } + } + } + }, + "Haptic Feedback" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haptic Feedback" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vibración" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retour haptique" + } + } + } + }, + "Hint: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hint: %@" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consejo: %@" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conseil: %@" + } + } + } + }, + "Hit" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hit" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pedir" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tirer" + } + } + } + }, + "Hit: Take another card" : { + "comment" : "Action available in Blackjack: Hit (take another card).", + "isCommentAutoGenerated" : true + }, + "How to Export Icons" : { + "comment" : "A section header explaining how to export app icons.", + "isCommentAutoGenerated" : true + }, + "How to Play" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How to Play" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cómo jugar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comment jouer" + } + } + } + }, + "Icon" : { + "comment" : "The label for the tab item representing the app icon preview.", + "isCommentAutoGenerated" : true + }, + "Icon Generator" : { + "comment" : "The title of the Icon Generator view.", + "isCommentAutoGenerated" : true + }, + "If both you and dealer have Blackjack, it's a push (tie)." : { + "comment" : "Description of the outcome when both the player and the dealer have a Blackjack.", + "isCommentAutoGenerated" : true + }, + "If the dealer busts and you haven't, you win." : { + "comment" : "Description of the outcome when the dealer busts and the player does not.", + "isCommentAutoGenerated" : true + }, + "If you go over 21, you 'bust' and lose immediately." : { + "comment" : "Description of the outcome when a player's hand value exceeds 21.", + "isCommentAutoGenerated" : true + }, + "Insurance" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insurance" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguro" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assurance" + } + } + } + }, + "INSURANCE LOSES" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "INSURANCE LOSES" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "SEGURO PIERDE" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "ASSURANCE PERDUE" + } + } + } + }, + "INSURANCE WINS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "INSURANCE WINS" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "SEGURO GANA" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "ASSURANCE GAGNÉE" + } + } + } + }, + "Insurance: 2:1" : { + "comment" : "Description of the payout for insurance in the game rules guide.", + "isCommentAutoGenerated" : true + }, + "INSURANCE?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "INSURANCE?" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿SEGURO?" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "ASSURANCE?" + } + } + } + }, + "Jack, Queen, King: 10" : { + "comment" : "Card value description for face cards (Jack, Queen, King).", + "isCommentAutoGenerated" : true + }, + "Late Surrender" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Late Surrender" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rendición tardía" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abandon tardif" + } + } + } + }, + "Launch" : { + "comment" : "A tab in the BrandingPreviewView that links to the launch screen preview.", + "isCommentAutoGenerated" : true + }, + "LOSE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "LOSE" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "PIERDE" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "PERDU" + } + } + } + }, + "Losses" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Losses" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pérdidas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pertes" + } + } + } + }, + "Lower house edge" : { + "comment" : "Description of a deck count option when the user selects 2 decks.", + "isCommentAutoGenerated" : true + }, + "Main Bet" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Main Bet" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apuesta principal" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mise principale" + } + } + } + }, + "Main Hand" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Main Hand" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mano principal" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Main principale" + } + } + } + }, + "Maximum penetration" : { + "comment" : "Description of a deck count option when the user selects 8 decks.", + "isCommentAutoGenerated" : true + }, + "Min: $%lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Min: $%lld" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mín: $%lld" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Min: $%lld" + } + } + } + }, + "Net" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Net" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neto" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Net" + } + } + } + }, + "NEW GAME" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NEW GAME" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "NUEVO JUEGO" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "NOUVELLE PARTIE" + } + } + } + }, + "New Round" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Round" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nueva ronda" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nouvelle partie" + } + } + } + }, + "No" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "No" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non" + } + } + } + }, + "No hole card, dealer stands on soft 17, no surrender" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hole card, dealer stands on soft 17, no surrender" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin carta oculta, crupier se planta en 17 suave, sin rendición" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas de carte cachée, croupier reste sur 17 souple, pas d'abandon" + } + } + } + }, + "Objective" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Objective" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Objetivo" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Objectif" + } + } + } + }, + "Offered when dealer shows an Ace." : { + "comment" : "Description of the insurance option in blackjack.", + "isCommentAutoGenerated" : true + }, + "Option 1: Screenshot from Preview" : { + "comment" : "A description of one method for exporting app icons.", + "isCommentAutoGenerated" : true + }, + "Option 2: Use IconRenderer in Code" : { + "comment" : "A subheading within the instructions section of the BrandingPreviewView.", + "isCommentAutoGenerated" : true + }, + "Other Game Icons" : { + "comment" : "A label displayed above a section of the BrandingPreviewView that shows icons for other games.", + "isCommentAutoGenerated" : true + }, + "Others" : { + "comment" : "A tab label for the section displaying icons for other games.", + "isCommentAutoGenerated" : true + }, + "OUTCOMES" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OUTCOMES" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "RESULTADOS" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "RÉSULTATS" + } + } + } + }, + "Payouts" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Payouts" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pagos" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paiements" + } + } + } + }, + "Pays 2:1 if dealer has Blackjack." : { + "comment" : "Description of the insurance payout when the player wins.", + "isCommentAutoGenerated" : true + }, + "Place bet" : { + "comment" : "An accessibility label for the betting zone when no bet is placed.", + "isCommentAutoGenerated" : true + }, + "Play Again" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play Again" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jugar de nuevo" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rejouer" + } + } + } + }, + "Player hand: %@. Value: %@" : { + "comment" : "A user-readable string describing a player's blackjack hand, including the card values and any relevant game results. The argument is a comma-separated list of the card descriptions in the player's hand.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Player hand: %1$@. Value: %2$@" + } + } + } + }, + "Poker" : { + + }, + "PUSH" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PUSH" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "EMPATE" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "ÉGALITÉ" + } + } + } + }, + "Push: Bet returned" : { + "comment" : "Description of the payout when the player and the dealer have the same hand value.", + "isCommentAutoGenerated" : true + }, + "Pushes" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pushes" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empates" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Égalités" + } + } + } + }, + "Re-split Aces" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Re-split Aces" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Re-dividir ases" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Re-séparer les as" + } + } + } + }, + "Roulette" : { + "comment" : "The name of a roulette card.", + "isCommentAutoGenerated" : true + }, + "Round result: %@" : { + "comment" : "An accessibility label for the round result banner, describing the main hand result.", + "isCommentAutoGenerated" : true + }, + "Rounds" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rounds" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rondas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parties" + } + } + } + }, + "RULES" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RULES" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "REGLAS" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "RÈGLES" + } + } + } + }, + "SESSION SUMMARY" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SESSION SUMMARY" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "RESUMEN DE SESIÓN" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "RÉSUMÉ DE SESSION" + } + } + } + }, + "Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paramètres" + } + } + } + }, + "Show Animations" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Animations" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar animaciones" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher les animations" + } + } + } + }, + "Show cards left in shoe" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show cards left in shoe" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar cartas en el zapato" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher cartes restantes" + } + } + } + }, + "Show Hints" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Hints" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar consejos" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher les conseils" + } + } + } + }, + "Single deck, higher variance" : { + "comment" : "Description of a deck count option when the user selects one deck.", + "isCommentAutoGenerated" : true + }, + "Some games: Dealer hits on 'soft 17' (Ace + 6)." : { + "comment" : "Description of a rule where the dealer must hit on a 'soft 17' (Ace + 6) in some blackjack games.", + "isCommentAutoGenerated" : true + }, + "SOUND & HAPTICS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SOUND & HAPTICS" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "SONIDO Y VIBRACIÓN" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "SON ET HAPTIQUE" + } + } + } + }, + "Sound Effects" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sound Effects" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efectos de sonido" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effets sonores" + } + } + } + }, + "Split" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Split" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dividir" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Séparer" + } + } + } + }, + "Split Hand" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Split Hand" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mano dividida" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Main séparée" + } + } + } + }, + "Split: If you have two cards of the same value, split into two hands" : { + "comment" : "Description of the 'Split' action in the game rules.", + "isCommentAutoGenerated" : true + }, + "Stand" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stand" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plantarse" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rester" + } + } + } + }, + "Stand: Keep your current hand" : { + "comment" : "Action to keep your current hand in Blackjack.", + "isCommentAutoGenerated" : true + }, + "Standard casino" : { + "comment" : "Description of a deck count option when the user selects 6 decks.", + "isCommentAutoGenerated" : true + }, + "Statistics" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statistics" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estadísticas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statistiques" + } + } + } + }, + "Surrender" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Surrender" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rendirse" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abandonner" + } + } + } + }, + "SURRENDER" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SURRENDER" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "RENDICIÓN" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "ABANDON" + } + } + } + }, + "Surrender after dealer checks for blackjack" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Surrender after dealer checks for blackjack" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rendirse después de que el crupier revise blackjack" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abandonner après vérification du blackjack" + } + } + } + }, + "Surrender: Give up half your bet and end the hand" : { + "comment" : "Description of the 'Surrender' action in the game rules.", + "isCommentAutoGenerated" : true + }, + "Surrender: Half bet returned" : { + "comment" : "Description of the payout for surrendering in Blackjack.", + "isCommentAutoGenerated" : true + }, + "Surrenders" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Surrenders" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rendiciones" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abandons" + } + } + } + }, + "TABLE LIMITS" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "TABLE LIMITS" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "LÍMITES DE MESA" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIMITES DE TABLE" + } + } + } + }, + "TAP TO BET" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "TAP TO BET" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "TOCA PARA APOSTAR" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "TOUCHEZ POUR MISER" + } + } + } + }, + "These show how the same pattern works for other games" : { + "comment" : "A description below the section of the view that previews icons for other games.", + "isCommentAutoGenerated" : true + }, + "Upload the 1024px icon to appicon.co or makeappicon.com to generate all sizes automatically." : { + "comment" : "A description of an alternative method for generating app icons.", + "isCommentAutoGenerated" : true + }, + "Vegas Strip" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vegas Strip" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vegas Strip" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vegas Strip" + } + } + } + }, + "Version %@ (%@)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version %1$@ (%2$@)" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión %1$@ (%2$@)" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version %1$@ (%2$@)" + } + } + } + }, + "Vibration on actions" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vibration on actions" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vibración en acciones" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vibration sur les actions" + } + } + } + }, + "Win Rate" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Win Rate" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tasa de victoria" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taux de victoire" + } + } + } + }, + "Win: 1:1 (even money)" : { + "comment" : "Payout description for a win in Blackjack.", + "isCommentAutoGenerated" : true + }, + "WIN!" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "WIN!" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡GANA!" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "GAGNÉ!" + } + } + } + }, + "Wins" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wins" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Victorias" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Victoires" + } + } + } + }, + "Worst" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Worst" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Peor" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pire" + } + } + } + }, + "Yes ($%lld)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yes ($%lld)" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sí ($%lld)" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oui ($%lld)" + } + } + } + }, + "You've run out of chips!" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You've run out of chips!" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Te quedaste sin fichas!" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vous n'avez plus de jetons!" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Blackjack/Storage/BlackjackGameData.swift b/Blackjack/Storage/BlackjackGameData.swift new file mode 100644 index 0000000..79e1d9a --- /dev/null +++ b/Blackjack/Storage/BlackjackGameData.swift @@ -0,0 +1,98 @@ +// +// BlackjackGameData.swift +// Blackjack +// +// Persistent game data model for iCloud sync. +// + +import Foundation +import CasinoKit + +/// Saved round result for history. +struct SavedRoundResult: Codable, Equatable { + let date: Date + let mainResult: String // "blackjack", "win", "lose", "push", "bust", "surrender" + let hadSplit: Bool + let totalWinnings: Int +} + +/// Persistent game data that syncs to iCloud. +struct BlackjackGameData: PersistableGameData { + static let gameIdentifier = "blackjack" + + var roundsPlayed: Int { roundHistory.count } + var lastModified: Date + + static var empty: BlackjackGameData { + BlackjackGameData( + lastModified: Date(), + balance: 10_000, + roundHistory: [], + totalWinnings: 0, + biggestWin: 0, + biggestLoss: 0, + blackjackCount: 0, + bustCount: 0 + ) + } + + var balance: Int + var roundHistory: [SavedRoundResult] + var totalWinnings: Int + var biggestWin: Int + var biggestLoss: Int + var blackjackCount: Int + var bustCount: Int +} + +/// Persistent settings data that syncs to iCloud. +struct BlackjackSettingsData: PersistableGameData { + static let gameIdentifier = "blackjack_settings" + var lastModified: Date + var roundsPlayed: Int = 0 // Settings don't track rounds, use 0 + + static var empty: BlackjackSettingsData { + BlackjackSettingsData( + lastModified: Date(), + roundsPlayed: 0, + gameStyle: "vegas", + deckCount: 6, + tableLimits: "low", + startingBalance: 10_000, + dealerHitsSoft17: false, + doubleAfterSplit: true, + resplitAces: false, + lateSurrender: true, + blackjackPayout: 1.5, + insuranceAllowed: true, + showAnimations: true, + dealingSpeed: 1.0, + showCardsRemaining: true, + showHistory: true, + showHints: true, + soundEnabled: true, + hapticsEnabled: true, + soundVolume: 1.0 + ) + } + + var gameStyle: String + var deckCount: Int + var tableLimits: String + var startingBalance: Int + var dealerHitsSoft17: Bool + var doubleAfterSplit: Bool + var resplitAces: Bool + var lateSurrender: Bool + var blackjackPayout: Double + var insuranceAllowed: Bool + var showAnimations: Bool + var dealingSpeed: Double + var showCardsRemaining: Bool + var showHistory: Bool + var showHints: Bool + var soundEnabled: Bool + var hapticsEnabled: Bool + var soundVolume: Float +} + diff --git a/Blackjack/Theme/DesignConstants.swift b/Blackjack/Theme/DesignConstants.swift new file mode 100644 index 0000000..3fbf77a --- /dev/null +++ b/Blackjack/Theme/DesignConstants.swift @@ -0,0 +1,202 @@ +// +// DesignConstants.swift +// Blackjack +// +// Centralized design constants for the Blackjack app. +// + +import SwiftUI +import CasinoKit + +// MARK: - Design Namespace + +enum Design { + // Reuse CasinoDesign where appropriate + typealias Animation = CasinoDesign.Animation + typealias Scale = CasinoDesign.Scale + typealias MinScaleFactor = CasinoDesign.MinScaleFactor + + // MARK: - Spacing + + enum Spacing { + static let xxSmall: CGFloat = 2 + static let xSmall: CGFloat = 4 + static let small: CGFloat = 8 + static let medium: CGFloat = 12 + static let large: CGFloat = 16 + static let xLarge: CGFloat = 20 + static let xxLarge: CGFloat = 24 + static let xxxLarge: CGFloat = 32 + } + + // MARK: - Corner Radius + + enum CornerRadius { + static let xSmall: CGFloat = 4 + static let small: CGFloat = 8 + static let medium: CGFloat = 12 + static let large: CGFloat = 16 + static let xLarge: CGFloat = 20 + static let xxLarge: CGFloat = 24 + static let xxxLarge: CGFloat = 32 + } + + // MARK: - Base Font Sizes + + enum BaseFontSize { + static let xxSmall: CGFloat = 8 + static let xSmall: CGFloat = 10 + static let small: CGFloat = 12 + static let body: CGFloat = 14 + static let medium: CGFloat = 16 + static let large: CGFloat = 18 + static let xLarge: CGFloat = 20 + static let xxLarge: CGFloat = 24 + static let title: CGFloat = 28 + static let largeTitle: CGFloat = 32 + static let display: CGFloat = 48 + } + + // MARK: - Opacity + + enum Opacity { + static let verySubtle: Double = 0.05 + static let subtle: Double = 0.1 + static let hint: Double = 0.2 + static let light: Double = 0.3 + static let medium: Double = 0.5 + static let accent: Double = 0.6 + static let strong: Double = 0.7 + static let heavy: Double = 0.8 + static let almostFull: Double = 0.9 + } + + // MARK: - Line Width + + enum LineWidth { + static let thin: CGFloat = 1 + static let medium: CGFloat = 2 + static let thick: CGFloat = 3 + static let heavy: CGFloat = 4 + } + + // MARK: - Shadow + + enum Shadow { + static let radiusSmall: CGFloat = 2 + static let radiusMedium: CGFloat = 6 + static let radiusLarge: CGFloat = 10 + static let radiusXLarge: CGFloat = 15 + static let offsetSmall: CGFloat = 1 + static let offsetMedium: CGFloat = 3 + static let offsetLarge: CGFloat = 5 + } + + // MARK: - Sizes + + enum Size { + // Cards + static let cardWidth: CGFloat = 55 + static let cardWidthSmall: CGFloat = 45 + static let cardOverlap: CGFloat = -15 + + // Table + static let tableHeight: CGFloat = 280 + static let bettingZoneHeight: CGFloat = 80 + static let chipBadgeSize: CGFloat = 32 + + // Buttons + static let actionButtonHeight: CGFloat = 50 + static let actionButtonMinWidth: CGFloat = 80 + + // Responsive + static let maxContentWidthPortrait: CGFloat = 500 + static let maxContentWidthLandscape: CGFloat = 800 + static let maxModalWidth: CGFloat = 450 + } + + // MARK: - Icon Sizes + + enum IconSize { + static let small: CGFloat = 16 + static let medium: CGFloat = 20 + static let large: CGFloat = 24 + static let xLarge: CGFloat = 32 + } +} + +// MARK: - Color Extensions + +extension Color { + // MARK: - Table Colors + + enum Table { + static let felt = Color(red: 0.05, green: 0.35, blue: 0.15) + static let feltDark = Color(red: 0.03, green: 0.25, blue: 0.1) + static let feltLight = Color(red: 0.08, green: 0.45, blue: 0.2) + static let border = Color(red: 0.6, green: 0.5, blue: 0.3) + } + + // MARK: - Betting Zone Colors + + enum BettingZone { + static let main = Color(red: 0.2, green: 0.4, blue: 0.3) + static let mainBorder = Color(red: 0.4, green: 0.6, blue: 0.4) + static let insurance = Color(red: 0.5, green: 0.4, blue: 0.2) + static let insuranceBorder = Color(red: 0.7, green: 0.6, blue: 0.3) + } + + // MARK: - Hand Colors + + enum Hand { + static let player = Color(red: 0.2, green: 0.5, blue: 0.8) + static let dealer = Color(red: 0.8, green: 0.3, blue: 0.3) + static let active = Color.yellow + static let inactive = Color.white.opacity(0.5) + } + + // MARK: - Result Colors + + enum Result { + static let win = Color.green + static let lose = Color.red + static let push = Color.blue + static let blackjack = Color.yellow + } + + // MARK: - Button Colors + + enum Button { + static let hit = Color(red: 0.2, green: 0.6, blue: 0.3) + static let stand = Color(red: 0.6, green: 0.4, blue: 0.1) + static let doubleDown = Color(red: 0.5, green: 0.3, blue: 0.6) + static let split = Color(red: 0.3, green: 0.5, blue: 0.7) + static let surrender = Color(red: 0.6, green: 0.3, blue: 0.3) + static let insurance = Color(red: 0.7, green: 0.6, blue: 0.2) + + static let goldLight = Color(red: 1.0, green: 0.85, blue: 0.3) + static let goldDark = Color(red: 0.9, green: 0.7, blue: 0.2) + } + + // MARK: - Settings Colors + + enum Settings { + static let background = Color(red: 0.08, green: 0.12, blue: 0.18) + static let cardBackground = Color.white.opacity(Design.Opacity.verySubtle) + static let accent = Color(red: 0.9, green: 0.75, blue: 0.3) + } + + // MARK: - Modal Colors + + enum Modal { + static let backgroundLight = Color(red: 0.15, green: 0.2, blue: 0.3) + static let backgroundDark = Color(red: 0.1, green: 0.15, blue: 0.25) + } + + // MARK: - TopBar Colors + + enum TopBar { + static let balance = Color(red: 0.95, green: 0.85, blue: 0.4) + } +} + diff --git a/Blackjack/Views/BlackjackTableView.swift b/Blackjack/Views/BlackjackTableView.swift new file mode 100644 index 0000000..0cebf8f --- /dev/null +++ b/Blackjack/Views/BlackjackTableView.swift @@ -0,0 +1,467 @@ +// +// BlackjackTableView.swift +// Blackjack +// +// The main table layout showing dealer and player hands. +// + +import SwiftUI +import CasinoKit + +struct BlackjackTableView: View { + @Bindable var state: GameState + let onPlaceBet: () -> Void + + // MARK: - Scaled Metrics + + @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium + @ScaledMetric(relativeTo: .title) private var valueFontSize: CGFloat = Design.BaseFontSize.xLarge + @ScaledMetric(relativeTo: .caption) private var hintFontSize: CGFloat = Design.BaseFontSize.small + + // MARK: - Layout + + private let cardWidth: CGFloat = Design.Size.cardWidth + private let cardSpacing: CGFloat = Design.Size.cardOverlap + + var body: some View { + VStack(spacing: Design.Spacing.large) { + // Dealer area + DealerHandView( + hand: state.dealerHand, + showHoleCard: shouldShowDealerHoleCard, + cardWidth: cardWidth, + cardSpacing: cardSpacing + ) + + Spacer() + + // Insurance zone (when offered) + if state.currentPhase == .insurance { + InsuranceZoneView( + betAmount: state.currentBet / 2, + balance: state.balance, + onTake: { Task { await state.takeInsurance() } }, + onDecline: { state.declineInsurance() } + ) + .transition(.scale.combined(with: .opacity)) + } + + // Player hands area + PlayerHandsView( + hands: state.playerHands, + activeHandIndex: state.activeHandIndex, + isPlayerTurn: isPlayerTurn, + cardWidth: cardWidth, + cardSpacing: cardSpacing + ) + + // Betting zone (when betting) + if state.currentPhase == .betting { + BettingZoneView( + betAmount: state.currentBet, + minBet: state.settings.minBet, + maxBet: state.settings.maxBet, + onTap: onPlaceBet + ) + .transition(.scale.combined(with: .opacity)) + } + + // Hint (when enabled and player turn) + if state.settings.showHints && isPlayerTurn, let hint = currentHint { + HintView(hint: hint) + .transition(.opacity) + } + } + .padding(.horizontal, Design.Spacing.large) + .padding(.vertical, Design.Spacing.medium) + .animation(.spring(duration: Design.Animation.springDuration), value: state.currentPhase) + } + + // MARK: - Computed Properties + + private var shouldShowDealerHoleCard: Bool { + switch state.currentPhase { + case .dealerTurn, .roundComplete: + return true + default: + return false + } + } + + private var isPlayerTurn: Bool { + if case .playerTurn = state.currentPhase { + return true + } + return false + } + + private var currentHint: String? { + guard let hand = state.activeHand, + let upCard = state.dealerUpCard else { return nil } + return state.engine.getHint(playerHand: hand, dealerUpCard: upCard) + } +} + +// MARK: - Dealer Hand View + +struct DealerHandView: View { + let hand: BlackjackHand + let showHoleCard: Bool + let cardWidth: CGFloat + let cardSpacing: CGFloat + + @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium + + var body: some View { + VStack(spacing: Design.Spacing.small) { + // Label and value + HStack(spacing: Design.Spacing.small) { + Text(String(localized: "DEALER")) + .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + if !hand.cards.isEmpty && showHoleCard { + ValueBadge(value: hand.value, color: Color.Hand.dealer) + } + } + + // Cards + HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) { + if hand.cards.isEmpty { + CardPlaceholderView(width: cardWidth) + CardPlaceholderView(width: cardWidth) + } else { + ForEach(hand.cards.indices, id: \.self) { index in + let isFaceUp = index == 0 || showHoleCard + CardView( + card: hand.cards[index], + isFaceUp: isFaceUp, + cardWidth: cardWidth + ) + .zIndex(Double(index)) + } + } + } + + // Result badge + if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil { + Text(result) + .font(.system(size: labelFontSize, weight: .black)) + .foregroundStyle(handResultColor) + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.xSmall) + .background( + Capsule() + .fill(handResultColor.opacity(Design.Opacity.hint)) + ) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(dealerAccessibilityLabel) + } + + private var handResultText: String? { + if hand.isBlackjack { + return String(localized: "BLACKJACK") + } + if hand.isBusted { + return String(localized: "BUST") + } + return nil + } + + private var handResultColor: Color { + if hand.isBlackjack { return .yellow } + if hand.isBusted { return .green } // Good for player + return .white + } + + private var dealerAccessibilityLabel: String { + if hand.cards.isEmpty { + return String(localized: "Dealer: No cards") + } + let visibleCards = showHoleCard ? hand.cards : [hand.cards[0]] + let cardsDescription = visibleCards.map { $0.accessibilityDescription }.joined(separator: ", ") + return String(localized: "Dealer: \(cardsDescription). Value: \(showHoleCard ? String(hand.value) : "hidden")") + } +} + +// MARK: - Player Hands View + +struct PlayerHandsView: View { + let hands: [BlackjackHand] + let activeHandIndex: Int + let isPlayerTurn: Bool + let cardWidth: CGFloat + let cardSpacing: CGFloat + + var body: some View { + HStack(spacing: Design.Spacing.xxLarge) { + ForEach(hands.indices, id: \.self) { index in + PlayerHandView( + hand: hands[index], + isActive: index == activeHandIndex && isPlayerTurn, + handNumber: hands.count > 1 ? index + 1 : nil, + cardWidth: cardWidth, + cardSpacing: cardSpacing + ) + } + } + } +} + +struct PlayerHandView: View { + let hand: BlackjackHand + let isActive: Bool + let handNumber: Int? + let cardWidth: CGFloat + let cardSpacing: CGFloat + + @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium + @ScaledMetric(relativeTo: .caption) private var handNumberSize: CGFloat = Design.BaseFontSize.small + + var body: some View { + VStack(spacing: Design.Spacing.small) { + // Cards + ZStack { + // Active indicator + if isActive { + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .strokeBorder(Color.Hand.active, lineWidth: Design.LineWidth.medium) + .frame(width: containerWidth, height: containerHeight) + .animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: isActive) + } + + HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) { + if hand.cards.isEmpty { + CardPlaceholderView(width: cardWidth) + CardPlaceholderView(width: cardWidth) + } else { + ForEach(hand.cards.indices, id: \.self) { index in + CardView( + card: hand.cards[index], + isFaceUp: true, + cardWidth: cardWidth + ) + .zIndex(Double(index)) + } + } + } + } + + // Hand info + HStack(spacing: Design.Spacing.small) { + if let number = handNumber { + Text(String(localized: "Hand \(number)")) + .font(.system(size: handNumberSize, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + + if !hand.cards.isEmpty { + Text(hand.valueDisplay) + .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) + .foregroundStyle(valueColor) + } + + if hand.isDoubledDown { + Image(systemName: "xmark.circle.fill") + .font(.system(size: handNumberSize)) + .foregroundStyle(.purple) + } + } + + // Result badge + if let result = hand.result { + Text(result.displayText) + .font(.system(size: labelFontSize, weight: .black)) + .foregroundStyle(result.color) + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.xSmall) + .background( + Capsule() + .fill(result.color.opacity(Design.Opacity.hint)) + ) + } + + // Bet amount + if hand.bet > 0 { + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: "dollarsign.circle.fill") + .foregroundStyle(.yellow) + Text("\(hand.bet * (hand.isDoubledDown ? 2 : 1))") + .font(.system(size: handNumberSize, weight: .bold, design: .rounded)) + .foregroundStyle(.yellow) + } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(playerAccessibilityLabel) + } + + private var containerWidth: CGFloat { + cardWidth + (cardWidth + cardSpacing) * 2 + Design.Spacing.medium + } + + private var containerHeight: CGFloat { + cardWidth * CasinoDesign.Size.cardAspectRatio + Design.Spacing.medium + } + + private var valueColor: Color { + if hand.isBlackjack { return .yellow } + if hand.isBusted { return .red } + if hand.value == 21 { return .green } + return .white + } + + private var playerAccessibilityLabel: String { + let cardsDescription = hand.cards.map { $0.accessibilityDescription }.joined(separator: ", ") + var label = String(localized: "Player hand: \(cardsDescription). Value: \(hand.valueDisplay)") + if let result = hand.result { + label += ". \(result.displayText)" + } + return label + } +} + +// MARK: - Betting Zone View + +struct BettingZoneView: View { + let betAmount: Int + let minBet: Int + let maxBet: Int + let onTap: () -> Void + + @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.large + + private var isAtMax: Bool { + betAmount >= maxBet + } + + var body: some View { + Button(action: onTap) { + ZStack { + // Background + RoundedRectangle(cornerRadius: Design.CornerRadius.large) + .fill(Color.BettingZone.main) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.large) + .strokeBorder(Color.BettingZone.mainBorder, lineWidth: Design.LineWidth.medium) + ) + + // Content + if betAmount > 0 { + // Show chip with amount + ChipOnTableView(amount: betAmount, showMax: isAtMax) + } else { + // Empty state + VStack(spacing: Design.Spacing.small) { + Text(String(localized: "TAP TO BET")) + .font(.system(size: labelFontSize, weight: .bold)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Text(String(localized: "Min: $\(minBet)")) + .font(.system(size: Design.BaseFontSize.small, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + } + } + } + .frame(maxWidth: .infinity) + .frame(height: Design.Size.bettingZoneHeight) + } + .buttonStyle(.plain) + .accessibilityLabel(betAmount > 0 ? "$\(betAmount) bet" + (isAtMax ? ", maximum" : "") : "Place bet") + .accessibilityHint("Double tap to add chips") + } +} + +// MARK: - Insurance Zone View + +struct InsuranceZoneView: View { + let betAmount: Int + let balance: Int + let onTake: () -> Void + let onDecline: () -> Void + + @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium + @ScaledMetric(relativeTo: .body) private var buttonFontSize: CGFloat = Design.BaseFontSize.body + + var body: some View { + VStack(spacing: Design.Spacing.medium) { + Text(String(localized: "INSURANCE?")) + .font(.system(size: labelFontSize, weight: .bold)) + .foregroundStyle(.yellow) + + Text(String(localized: "Dealer showing Ace")) + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + HStack(spacing: Design.Spacing.large) { + Button(action: onDecline) { + Text(String(localized: "No")) + .font(.system(size: buttonFontSize, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, Design.Spacing.xxLarge) + .padding(.vertical, Design.Spacing.medium) + .background( + Capsule() + .fill(Color.Button.surrender) + ) + } + + if balance >= betAmount { + Button(action: onTake) { + Text(String(localized: "Yes ($\(betAmount))")) + .font(.system(size: buttonFontSize, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, Design.Spacing.xxLarge) + .padding(.vertical, Design.Spacing.medium) + .background( + Capsule() + .fill(Color.Button.insurance) + ) + } + } + } + } + .padding(Design.Spacing.large) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.large) + .fill(Color.BettingZone.insurance.opacity(Design.Opacity.heavy)) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.large) + .strokeBorder(Color.BettingZone.insuranceBorder, lineWidth: Design.LineWidth.medium) + ) + ) + } +} + +// MARK: - Hint View + +struct HintView: View { + let hint: String + + var body: some View { + HStack(spacing: Design.Spacing.small) { + Image(systemName: "lightbulb.fill") + .foregroundStyle(.yellow) + Text(String(localized: "Hint: \(hint)")) + .font(.system(size: Design.BaseFontSize.small, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + } + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background( + Capsule() + .fill(Color.black.opacity(Design.Opacity.light)) + ) + } +} + +// MARK: - Card Accessibility Extension + +extension Card { + var accessibilityDescription: String { + "\(rank.accessibilityName) of \(suit.accessibilityName)" + } +} + diff --git a/Blackjack/Views/BrandingPreviewView.swift b/Blackjack/Views/BrandingPreviewView.swift new file mode 100644 index 0000000..becec81 --- /dev/null +++ b/Blackjack/Views/BrandingPreviewView.swift @@ -0,0 +1,130 @@ +// +// BrandingPreviewView.swift +// Blackjack +// +// Development view for previewing and exporting app icons and launch screens. +// Access this during development to generate icon assets. +// + +import SwiftUI +import CasinoKit + +/// Preview view for app branding assets. +/// Use this during development to preview and export icons. +struct BrandingPreviewView: View { + var body: some View { + TabView { + // App Icon Preview + ScrollView { + VStack(spacing: Design.Spacing.xxxLarge) { + Text("App Icon") + .font(.largeTitle.bold()) + + AppIconView(config: .blackjack, size: 300) + .clipShape(.rect(cornerRadius: 300 * 0.22)) + .shadow(radius: Design.Shadow.radiusXLarge) + + Text("All Sizes") + .font(.title2.bold()) + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: Design.Spacing.xLarge) { + ForEach([180, 120, 87, 60, 40], id: \.self) { size in + VStack { + AppIconView(config: .blackjack, size: CGFloat(size)) + .clipShape(.rect(cornerRadius: CGFloat(size) * 0.22)) + Text("\(size)px") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + instructionsSection + } + .padding() + } + .tabItem { + Label("Icon", systemImage: "app.fill") + } + + // Launch Screen Preview + LaunchScreenView(config: .blackjack) + .tabItem { + Label("Launch", systemImage: "rectangle.portrait.fill") + } + + // Other Games Preview + ScrollView { + VStack(spacing: Design.Spacing.xxxLarge) { + Text("Other Game Icons") + .font(.largeTitle.bold()) + + HStack(spacing: Design.Spacing.xLarge) { + VStack { + AppIconView(config: .baccarat, size: 150) + .clipShape(.rect(cornerRadius: 150 * 0.22)) + Text("Baccarat") + .font(.caption) + } + + VStack { + AppIconView(config: .poker, size: 150) + .clipShape(.rect(cornerRadius: 150 * 0.22)) + Text("Poker") + .font(.caption) + } + + VStack { + AppIconView(config: .roulette, size: 150) + .clipShape(.rect(cornerRadius: 150 * 0.22)) + Text("Roulette") + .font(.caption) + } + } + + Text("These show how the same pattern works for other games") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding() + } + .tabItem { + Label("Others", systemImage: "square.grid.2x2") + } + } + } + + private var instructionsSection: some View { + VStack(alignment: .leading, spacing: Design.Spacing.medium) { + Text("How to Export Icons") + .font(.headline) + + VStack(alignment: .leading, spacing: Design.Spacing.small) { + Text("Option 1: Screenshot from Preview") + .font(.subheadline.bold()) + Text("• Run the preview in Xcode") + Text("• Screenshot the 1024px icon") + Text("• Use an online tool to generate all sizes") + } + + VStack(alignment: .leading, spacing: Design.Spacing.small) { + Text("Option 2: Use IconRenderer in Code") + .font(.subheadline.bold()) + Text("• Call IconRenderer.renderAppIcon(config: .blackjack)") + Text("• Save the resulting UIImage to files") + Text("• Add to Assets.xcassets/AppIcon") + } + } + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color.gray.opacity(Design.Opacity.subtle)) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + } +} + +#Preview { + BrandingPreviewView() +} + diff --git a/Blackjack/Views/GameTableView.swift b/Blackjack/Views/GameTableView.swift new file mode 100644 index 0000000..0989ec7 --- /dev/null +++ b/Blackjack/Views/GameTableView.swift @@ -0,0 +1,335 @@ +// +// GameTableView.swift +// Blackjack +// +// Main game container view. +// + +import SwiftUI +import CasinoKit + +struct GameTableView: View { + @State private var settings = GameSettings() + @State private var gameState: GameState? + @State private var selectedChip: ChipDenomination = .twentyFive + + // MARK: - Sheet State + + @State private var showSettings = false + @State private var showRules = false + @State private var showStats = false + + // MARK: - Environment + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + + /// Whether we're on iPad + private var isIPad: Bool { + horizontalSizeClass == .regular + } + + /// Maximum content width based on device + private var maxContentWidth: CGFloat { + if isIPad { + return verticalSizeClass == .compact + ? Design.Size.maxContentWidthLandscape + : Design.Size.maxContentWidthPortrait + } + return .infinity + } + + // MARK: - Body + + var body: some View { + Group { + if let state = gameState { + mainGameView(state: state) + } else { + ProgressView() + .task { + gameState = GameState(settings: settings) + } + } + } + .sheet(isPresented: $showSettings) { + SettingsView(settings: settings, gameState: gameState) + } + .sheet(isPresented: $showRules) { + RulesHelpView() + } + .sheet(isPresented: $showStats) { + if let state = gameState { + StatisticsSheetView(state: state) + } + } + } + + // MARK: - Main Game View + + @ViewBuilder + private func mainGameView(state: GameState) -> some View { + ZStack { + // Background + TableBackgroundView( + feltColor: Color.Table.felt, + edgeColor: Color.Table.feltDark + ) + + VStack(spacing: 0) { + // Top bar + TopBarView( + balance: state.balance, + secondaryInfo: settings.showCardsRemaining ? "\(state.engine.cardsRemaining)" : nil, + onReset: { state.resetGame() }, + onSettings: { showSettings = true }, + onHelp: { showRules = true }, + onStats: { showStats = true } + ) + .frame(maxWidth: maxContentWidth) + + // Table layout + BlackjackTableView( + state: state, + onPlaceBet: { placeBet(state: state) } + ) + .frame(maxWidth: maxContentWidth) + + Spacer() + + // Chip selector + ChipSelectorView( + selectedChip: $selectedChip, + balance: state.balance, + maxBet: state.settings.maxBet + ) + .frame(maxWidth: maxContentWidth) + .padding(.bottom, Design.Spacing.small) + + // Action buttons + ActionButtonsView(state: state) + .frame(maxWidth: maxContentWidth) + .padding(.bottom, Design.Spacing.medium) + } + .frame(maxWidth: .infinity) + + // Result banner overlay + if state.showResultBanner, let result = state.lastRoundResult { + ResultBannerView( + result: result, + currentBalance: state.balance, + minBet: state.settings.minBet, + onNewRound: { state.newRound() }, + onPlayAgain: { state.resetGame() } + ) + } + + // Confetti for blackjack + if state.showResultBanner && (state.lastRoundResult?.wasBlackjack ?? false) { + ConfettiView() + } + + // Game over + if state.isGameOver && !state.showResultBanner { + GameOverView( + roundsPlayed: state.roundsPlayed, + onPlayAgain: { state.resetGame() } + ) + } + } + } + + // MARK: - Betting + + private func placeBet(state: GameState) { + state.placeBet(amount: selectedChip.rawValue) + } +} + +// MARK: - Action Buttons View + +struct ActionButtonsView: View { + @Bindable var state: GameState + + // Scaled metrics + @ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = Design.BaseFontSize.large + @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.IconSize.large + + // Fixed height to prevent layout shifts + private let containerHeight: CGFloat = 120 + + var body: some View { + ZStack { + Color.clear + .frame(height: containerHeight) + + VStack(spacing: Design.Spacing.medium) { + // Primary actions + HStack(spacing: Design.Spacing.medium) { + switch state.currentPhase { + case .betting: + bettingButtons + case .playerTurn: + playerTurnButtons + case .roundComplete: + // Empty - handled by result banner + EmptyView() + default: + // Dealing, dealer turn - show nothing + EmptyView() + } + } + .animation(.spring(duration: Design.Animation.quick), value: state.currentPhase) + } + } + .padding(.horizontal, Design.Spacing.large) + } + + // MARK: - Betting Phase Buttons + + @ViewBuilder + private var bettingButtons: some View { + if state.currentBet > 0 { + ActionButton( + String(localized: "Clear"), + icon: "xmark.circle", + style: .destructive + ) { + state.clearBet() + } + + if state.currentBet >= state.settings.minBet { + ActionButton( + String(localized: "Deal"), + icon: "play.fill", + style: .primary + ) { + Task { await state.deal() } + } + } + } + } + + // MARK: - Player Turn Buttons + + @ViewBuilder + private var playerTurnButtons: some View { + // Top row: Hit, Stand + HStack(spacing: Design.Spacing.medium) { + if state.canHit { + ActionButton( + String(localized: "Hit"), + style: .custom(Color.Button.hit) + ) { + Task { await state.hit() } + } + } + + if state.canStand { + ActionButton( + String(localized: "Stand"), + style: .custom(Color.Button.stand) + ) { + Task { await state.stand() } + } + } + } + + // Bottom row: Double, Split, Surrender + HStack(spacing: Design.Spacing.medium) { + if state.canDouble { + ActionButton( + String(localized: "Double"), + style: .custom(Color.Button.doubleDown) + ) { + Task { await state.doubleDown() } + } + } + + if state.canSplit { + ActionButton( + String(localized: "Split"), + style: .custom(Color.Button.split) + ) { + Task { await state.split() } + } + } + + if state.canSurrender { + ActionButton( + String(localized: "Surrender"), + style: .custom(Color.Button.surrender) + ) { + Task { await state.surrender() } + } + } + } + } +} + +// MARK: - Action Button + +struct ActionButton: View { + let title: String + let icon: String? + let style: ButtonStyle + let action: () -> Void + + enum ButtonStyle { + case primary + case destructive + case secondary + case custom(Color) + + var foregroundColor: Color { + switch self { + case .primary: return .black + case .destructive, .secondary, .custom: return .white + } + } + + var backgroundColor: Color { + switch self { + case .primary: return .yellow + case .destructive: return .red.opacity(Design.Opacity.heavy) + case .secondary: return .white.opacity(Design.Opacity.hint) + case .custom(let color): return color + } + } + } + + init(_ title: String, icon: String? = nil, style: ButtonStyle = .primary, action: @escaping () -> Void) { + self.title = title + self.icon = icon + self.style = style + self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: Design.Spacing.small) { + if let icon = icon { + Image(systemName: icon) + } + Text(title) + } + .font(.system(size: Design.BaseFontSize.medium, weight: .bold)) + .foregroundStyle(style.foregroundColor) + .padding(.horizontal, Design.Spacing.xLarge) + .padding(.vertical, Design.Spacing.medium) + .background( + Capsule() + .fill(style.backgroundColor) + ) + .shadow(color: style.backgroundColor.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) + } + .accessibilityLabel(title) + } +} + +// MARK: - Preview + +#Preview { + GameTableView() +} + diff --git a/Blackjack/Views/IconGeneratorView.swift b/Blackjack/Views/IconGeneratorView.swift new file mode 100644 index 0000000..207fcf4 --- /dev/null +++ b/Blackjack/Views/IconGeneratorView.swift @@ -0,0 +1,196 @@ +// +// IconGeneratorView.swift +// Blackjack +// +// Development tool to generate and export app icon images. +// Run this view, tap the button, then find the icons in the Files app. +// + +import SwiftUI +import CasinoKit + +/// A development view that generates and saves app icon images. +/// After running, find the icons in Files app → On My iPhone → Blackjack +struct IconGeneratorView: View { + @State private var status: String = "Tap the button to generate icons" + @State private var isGenerating = false + @State private var generatedIcons: [GeneratedIcon] = [] + + // Development view: hardcoded sizes acceptable + private let previewSize: CGFloat = 200 + private let iconCornerRadiusRatio: CGFloat = 0.22 + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: Design.Spacing.xxLarge) { + // Preview + AppIconView(config: .blackjack, size: previewSize) + .clipShape(.rect(cornerRadius: previewSize * iconCornerRadiusRatio)) + .shadow(radius: 10) + + Text("App Icon Preview") + .font(.headline) + + // Generate button + Button { + Task { + await generateIcons() + } + } label: { + HStack { + if isGenerating { + ProgressView() + .tint(.white) + } + Text(isGenerating ? "Generating..." : "Generate & Save Icons") + } + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding() + .background(isGenerating ? Color.gray : Color.blue) + .clipShape(.rect(cornerRadius: 12)) + } + .disabled(isGenerating) + .padding(.horizontal) + + // Status + Text(status) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + // Generated icons + if !generatedIcons.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("Generated Icons:") + .font(.headline) + + ForEach(generatedIcons) { icon in + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(icon.filename) + .font(.caption.monospaced()) + Spacer() + Text("\(Int(icon.size))px") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding() + .background(Color.green.opacity(Design.Opacity.subtle)) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + .padding(.horizontal) + } + + // Instructions + instructionsSection + } + .padding(.vertical) + } + .navigationTitle("Icon Generator") + } + } + + private var instructionsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("After generating:") + .font(.headline) + + VStack(alignment: .leading, spacing: Design.Spacing.small) { + instructionRow(number: 1, text: "Open Files app on your device/simulator") + instructionRow(number: 2, text: "Navigate to: On My iPhone → Blackjack") + instructionRow(number: 3, text: "Find the AppIcon-1024.png file") + instructionRow(number: 4, text: "AirDrop or share to your Mac") + instructionRow(number: 5, text: "Drag into Xcode's Assets.xcassets/AppIcon") + } + + Divider() + + Text("Alternative: Use an online tool") + .font(.subheadline.bold()) + Text("Upload the 1024px icon to appicon.co or makeappicon.com to generate all sizes automatically.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .background(Color.gray.opacity(Design.Opacity.subtle)) + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + .padding(.horizontal) + } + + private func instructionRow(number: Int, text: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text("\(number).") + .font(.callout.bold()) + .foregroundStyle(.blue) + Text(text) + .font(.callout) + } + } + + @MainActor + private func generateIcons() async { + isGenerating = true + generatedIcons = [] + status = "Generating icons..." + + let sizes: [(CGFloat, String)] = [ + (1024, "AppIcon-1024"), + (180, "AppIcon-180"), + (120, "AppIcon-120"), + (87, "AppIcon-87"), + (80, "AppIcon-80"), + (60, "AppIcon-60"), + (40, "AppIcon-40") + ] + + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + + for (size, name) in sizes { + // Render the icon + let view = AppIconView(config: .blackjack, size: size) + let renderer = ImageRenderer(content: view) + renderer.scale = 1.0 + + if let uiImage = renderer.uiImage, + let data = uiImage.pngData() { + let filename = "\(name).png" + let fileURL = documentsPath.appending(path: filename) + + do { + try data.write(to: fileURL) + generatedIcons.append(GeneratedIcon(filename: filename, size: size)) + } catch { + status = "Error saving \(filename): \(error.localizedDescription)" + } + } + + // Small delay for UI feedback + try? await Task.sleep(for: .milliseconds(100)) + } + + if generatedIcons.count == sizes.count { + status = "✅ All icons saved to Documents folder!\nOpen Files app to find them." + } else { + status = "⚠️ Some icons failed to generate" + } + + isGenerating = false + } +} + +struct GeneratedIcon: Identifiable { + let id = UUID() + let filename: String + let size: CGFloat +} + +#Preview { + IconGeneratorView() +} + diff --git a/Blackjack/Views/ResultBannerView.swift b/Blackjack/Views/ResultBannerView.swift new file mode 100644 index 0000000..b0f93cf --- /dev/null +++ b/Blackjack/Views/ResultBannerView.swift @@ -0,0 +1,217 @@ +// +// ResultBannerView.swift +// Blackjack +// +// Displays the result of a round with breakdown. +// + +import SwiftUI +import CasinoKit + +struct ResultBannerView: View { + let result: RoundResult + let currentBalance: Int + let minBet: Int + let onNewRound: () -> Void + let onPlayAgain: () -> Void + + @State private var showContent = false + + // MARK: - Scaled Metrics + + @ScaledMetric(relativeTo: .largeTitle) private var titleFontSize: CGFloat = Design.BaseFontSize.largeTitle + @ScaledMetric(relativeTo: .title) private var resultFontSize: CGFloat = Design.BaseFontSize.title + @ScaledMetric(relativeTo: .headline) private var amountFontSize: CGFloat = Design.BaseFontSize.xLarge + @ScaledMetric(relativeTo: .body) private var buttonFontSize: CGFloat = Design.BaseFontSize.medium + + // MARK: - Computed + + private var isGameOver: Bool { + currentBalance < minBet + } + + private var mainResultColor: Color { + result.mainHandResult.color + } + + private var winningsText: String { + if result.totalWinnings > 0 { + return "+$\(result.totalWinnings)" + } else if result.totalWinnings < 0 { + return "-$\(abs(result.totalWinnings))" + } else { + return "$0" + } + } + + private var winningsColor: Color { + if result.totalWinnings > 0 { return .green } + if result.totalWinnings < 0 { return .red } + return .blue + } + + var body: some View { + ZStack { + // Full screen dark background + Color.black.opacity(Design.Opacity.strong) + .ignoresSafeArea() + + // Content card + VStack(spacing: Design.Spacing.xLarge) { + // Main result + Text(result.mainHandResult.displayText) + .font(.system(size: titleFontSize, weight: .black, design: .rounded)) + .foregroundStyle(mainResultColor) + + // Winnings + Text(winningsText) + .font(.system(size: amountFontSize, weight: .bold, design: .rounded)) + .foregroundStyle(winningsColor) + + // Breakdown + VStack(spacing: Design.Spacing.small) { + ResultRow(label: String(localized: "Main Hand"), result: result.mainHandResult) + + if let splitResult = result.splitHandResult { + ResultRow(label: String(localized: "Split Hand"), result: splitResult) + } + + if let insuranceResult = result.insuranceResult { + ResultRow(label: String(localized: "Insurance"), result: insuranceResult) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.white.opacity(Design.Opacity.subtle)) + ) + + // Game over message + if isGameOver { + VStack(spacing: Design.Spacing.small) { + Text(String(localized: "You've run out of chips!")) + .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + + Button(action: onPlayAgain) { + HStack(spacing: Design.Spacing.small) { + Image(systemName: "arrow.counterclockwise") + Text(String(localized: "Play Again")) + } + .font(.system(size: buttonFontSize, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, Design.Spacing.xxLarge) + .padding(.vertical, Design.Spacing.medium) + .background( + Capsule() + .fill( + LinearGradient( + colors: [Color.Button.goldLight, Color.Button.goldDark], + startPoint: .top, + endPoint: .bottom + ) + ) + ) + } + } + .onAppear { + Task { + try? await Task.sleep(for: .milliseconds(300)) + SoundManager.shared.play(.gameOver) + } + } + } else { + // New Round button + Button(action: onNewRound) { + HStack(spacing: Design.Spacing.small) { + Image(systemName: "arrow.clockwise") + Text(String(localized: "New Round")) + } + .font(.system(size: buttonFontSize, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, Design.Spacing.xxLarge) + .padding(.vertical, Design.Spacing.medium) + .background( + Capsule() + .fill( + LinearGradient( + colors: [Color.Button.goldLight, Color.Button.goldDark], + startPoint: .top, + endPoint: .bottom + ) + ) + ) + } + } + } + .padding(Design.Spacing.xxLarge) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge) + .fill( + LinearGradient( + colors: [Color.Modal.backgroundLight, Color.Modal.backgroundDark], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge) + .strokeBorder( + mainResultColor.opacity(Design.Opacity.medium), + lineWidth: Design.LineWidth.medium + ) + ) + ) + .shadow(color: mainResultColor.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXLarge) + .frame(maxWidth: Design.Size.maxModalWidth) + .scaleEffect(showContent ? 1.0 : 0.8) + .opacity(showContent ? 1.0 : 0) + } + .onAppear { + withAnimation(.spring(duration: Design.Animation.springDuration, bounce: 0.3)) { + showContent = true + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(String(localized: "Round result: \(result.mainHandResult.displayText)")) + .accessibilityAddTraits(AccessibilityTraits.isModal) + } +} + +// MARK: - Result Row + +struct ResultRow: View { + let label: String + let result: HandResult + + var body: some View { + HStack { + Text(label) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + + Spacer() + + Text(result.displayText) + .font(.system(size: Design.BaseFontSize.body, weight: .bold)) + .foregroundStyle(result.color) + } + } +} + +#Preview { + ResultBannerView( + result: RoundResult( + mainHandResult: .blackjack, + splitHandResult: nil, + insuranceResult: nil, + totalWinnings: 150, + wasBlackjack: true + ), + currentBalance: 10150, + minBet: 10, + onNewRound: {}, + onPlayAgain: {} + ) +} + diff --git a/Blackjack/Views/RulesHelpView.swift b/Blackjack/Views/RulesHelpView.swift new file mode 100644 index 0000000..4e71d7d --- /dev/null +++ b/Blackjack/Views/RulesHelpView.swift @@ -0,0 +1,186 @@ +// +// RulesHelpView.swift +// Blackjack +// +// Game rules and how to play guide. +// + +import SwiftUI +import CasinoKit + +struct RulesHelpView: View { + @Environment(\.dismiss) private var dismiss + @State private var currentPage = 0 + + private let pages: [RulePage] = [ + RulePage( + title: String(localized: "Objective"), + icon: "target", + content: [ + String(localized: "Beat the dealer by getting a hand value closer to 21 without going over."), + String(localized: "If you go over 21, you 'bust' and lose immediately."), + String(localized: "If the dealer busts and you haven't, you win.") + ] + ), + RulePage( + title: String(localized: "Card Values"), + icon: "suit.spade.fill", + content: [ + String(localized: "2-10: Face value"), + String(localized: "Jack, Queen, King: 10"), + String(localized: "Ace: 1 or 11 (whichever helps your hand)"), + String(localized: "A 'soft' hand has an Ace counting as 11.") + ] + ), + RulePage( + title: String(localized: "Blackjack"), + icon: "star.fill", + content: [ + String(localized: "An Ace + 10-value card dealt initially is 'Blackjack'."), + String(localized: "Blackjack pays 3:2 (1.5x your bet)."), + String(localized: "If both you and dealer have Blackjack, it's a push (tie).") + ] + ), + RulePage( + title: String(localized: "Actions"), + icon: "hand.tap.fill", + content: [ + String(localized: "Hit: Take another card"), + String(localized: "Stand: Keep your current hand"), + String(localized: "Double Down: Double your bet, take one card, then stand"), + String(localized: "Split: If you have two cards of the same value, split into two hands"), + String(localized: "Surrender: Give up half your bet and end the hand") + ] + ), + RulePage( + title: String(localized: "Insurance"), + icon: "shield.fill", + content: [ + String(localized: "Offered when dealer shows an Ace."), + String(localized: "Costs half your original bet."), + String(localized: "Pays 2:1 if dealer has Blackjack."), + String(localized: "Generally not recommended by basic strategy.") + ] + ), + RulePage( + title: String(localized: "Dealer Rules"), + icon: "person.fill", + content: [ + String(localized: "Dealer must hit on 16 or less."), + String(localized: "Dealer must stand on 17 or more (varies by rules)."), + String(localized: "Some games: Dealer hits on 'soft 17' (Ace + 6).") + ] + ), + RulePage( + title: String(localized: "Payouts"), + icon: "dollarsign.circle.fill", + content: [ + String(localized: "Win: 1:1 (even money)"), + String(localized: "Blackjack: 3:2"), + String(localized: "Insurance: 2:1"), + String(localized: "Push: Bet returned"), + String(localized: "Surrender: Half bet returned") + ] + ) + ] + + var body: some View { + NavigationStack { + ZStack { + Color.Settings.background + .ignoresSafeArea() + + VStack(spacing: 0) { + // Page content + TabView(selection: $currentPage) { + ForEach(pages.indices, id: \.self) { index in + RulePageView(page: pages[index]) + .tag(index) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + + // Page indicator + HStack(spacing: Design.Spacing.small) { + ForEach(pages.indices, id: \.self) { index in + Circle() + .fill(index == currentPage ? Color.Settings.accent : Color.white.opacity(Design.Opacity.light)) + .frame(width: 8, height: 8) + } + } + .padding(.vertical, Design.Spacing.medium) + } + } + .navigationTitle(String(localized: "How to Play")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(String(localized: "Done")) { + dismiss() + } + .foregroundStyle(Color.Settings.accent) + } + } + .toolbarBackground(Color.Settings.background, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + } + } +} + +// MARK: - Rule Page Model + +struct RulePage: Identifiable { + let id = UUID() + let title: String + let icon: String + let content: [String] +} + +// MARK: - Rule Page View + +struct RulePageView: View { + let page: RulePage + + @ScaledMetric(relativeTo: .title) private var iconSize: CGFloat = Design.BaseFontSize.display + @ScaledMetric(relativeTo: .title) private var titleSize: CGFloat = Design.BaseFontSize.title + @ScaledMetric(relativeTo: .body) private var bodySize: CGFloat = Design.BaseFontSize.body + + var body: some View { + ScrollView { + VStack(spacing: Design.Spacing.xLarge) { + // Icon + Image(systemName: page.icon) + .font(.system(size: iconSize)) + .foregroundStyle(Color.Settings.accent) + .padding(.top, Design.Spacing.xxLarge) + + // Title + Text(page.title) + .font(.system(size: titleSize, weight: .bold)) + .foregroundStyle(.white) + + // Content + VStack(alignment: .leading, spacing: Design.Spacing.medium) { + ForEach(page.content.indices, id: \.self) { index in + HStack(alignment: .top, spacing: Design.Spacing.medium) { + Text("•") + .foregroundStyle(Color.Settings.accent) + + Text(page.content[index]) + .font(.system(size: bodySize)) + .foregroundStyle(.white.opacity(Design.Opacity.heavy)) + } + } + } + .padding(.horizontal, Design.Spacing.xxLarge) + + Spacer() + } + } + } +} + +#Preview { + RulesHelpView() +} + diff --git a/Blackjack/Views/SettingsView.swift b/Blackjack/Views/SettingsView.swift new file mode 100644 index 0000000..2811529 --- /dev/null +++ b/Blackjack/Views/SettingsView.swift @@ -0,0 +1,282 @@ +// +// SettingsView.swift +// Blackjack +// +// Game settings and rule configuration. +// + +import SwiftUI +import CasinoKit + +struct SettingsView: View { + @Bindable var settings: GameSettings + let gameState: GameState? + + @Environment(\.dismiss) private var dismiss + + var body: some View { + SheetContainerView( + title: String(localized: "Settings"), + content: { + // Game Style + SheetSection(title: String(localized: "GAME STYLE"), icon: "suit.club.fill") { + GameStylePicker(selection: $settings.gameStyle) + } + + // Deck Settings + SheetSection(title: String(localized: "DECK SETTINGS"), icon: "rectangle.portrait.on.rectangle.portrait") { + DeckCountPicker(selection: $settings.deckCount) + } + + // Table Limits + SheetSection(title: String(localized: "TABLE LIMITS"), icon: "banknote") { + TableLimitsPicker(selection: $settings.tableLimits) + } + + // Rule Options (for custom style) + if settings.gameStyle == .custom { + SheetSection(title: String(localized: "RULES"), icon: "list.bullet.clipboard") { + VStack(spacing: Design.Spacing.small) { + SettingsToggle( + title: String(localized: "Dealer Hits Soft 17"), + subtitle: String(localized: "H17 rule, increases house edge"), + isOn: $settings.dealerHitsSoft17 + ) + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + SettingsToggle( + title: String(localized: "Double After Split"), + subtitle: String(localized: "Allow doubling on split hands"), + isOn: $settings.doubleAfterSplit + ) + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + SettingsToggle( + title: String(localized: "Re-split Aces"), + subtitle: String(localized: "Allow splitting aces again"), + isOn: $settings.resplitAces + ) + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + SettingsToggle( + title: String(localized: "Late Surrender"), + subtitle: String(localized: "Surrender after dealer checks for blackjack"), + isOn: $settings.lateSurrender + ) + } + } + } + + // Display + SheetSection(title: String(localized: "DISPLAY"), icon: "eye") { + VStack(spacing: Design.Spacing.small) { + SettingsToggle( + title: String(localized: "Show Animations"), + subtitle: String(localized: "Card dealing animations"), + isOn: $settings.showAnimations + ) + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + SettingsToggle( + title: String(localized: "Show Hints"), + subtitle: String(localized: "Basic strategy suggestions"), + isOn: $settings.showHints + ) + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + SettingsToggle( + title: String(localized: "Cards Remaining"), + subtitle: String(localized: "Show cards left in shoe"), + isOn: $settings.showCardsRemaining + ) + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + SpeedPicker(speed: $settings.dealingSpeed) + } + } + + // Sound & Haptics + SheetSection(title: String(localized: "SOUND & HAPTICS"), icon: "speaker.wave.2") { + VStack(spacing: Design.Spacing.small) { + SettingsToggle( + title: String(localized: "Sound Effects"), + subtitle: String(localized: "Chips, cards, and results"), + isOn: $settings.soundEnabled + ) + .onChange(of: settings.soundEnabled) { _, newValue in + SoundManager.shared.soundEnabled = newValue + } + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + SettingsToggle( + title: String(localized: "Haptic Feedback"), + subtitle: String(localized: "Vibration on actions"), + isOn: $settings.hapticsEnabled + ) + .onChange(of: settings.hapticsEnabled) { _, newValue in + SoundManager.shared.hapticsEnabled = newValue + } + + Divider().background(Color.white.opacity(Design.Opacity.hint)) + + VolumePicker(volume: $settings.soundVolume) + .onChange(of: settings.soundVolume) { _, newValue in + SoundManager.shared.volume = newValue + } + } + } + + // Starting Balance + SheetSection(title: String(localized: "NEW GAME"), icon: "dollarsign.circle") { + BalancePicker(balance: $settings.startingBalance) + } + + // Version info + if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String { + Text(String(localized: "Version \(version) (\(build))")) + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + .frame(maxWidth: .infinity) + .padding(.top, Design.Spacing.large) + } + }, + onCancel: nil, + onDone: { + settings.save() + dismiss() + }, + doneButtonText: String(localized: "Done") + ) + } +} + +// MARK: - Game Style Picker + +struct GameStylePicker: View { + @Binding var selection: BlackjackStyle + + var body: some View { + VStack(spacing: Design.Spacing.small) { + ForEach(BlackjackStyle.allCases) { style in + Button { + selection = style + } label: { + HStack { + VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + Text(style.displayName) + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .foregroundStyle(.white) + + Text(style.description) + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + .lineLimit(2) + } + + Spacer() + + if selection == style { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.Settings.accent) + } + } + .padding(.vertical, Design.Spacing.small) + } + .buttonStyle(.plain) + + if style != BlackjackStyle.allCases.last { + Divider().background(Color.white.opacity(Design.Opacity.hint)) + } + } + } + } +} + +// MARK: - Deck Count Picker + +struct DeckCountPicker: View { + @Binding var selection: DeckCount + + var body: some View { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: Design.Spacing.small) { + ForEach(DeckCount.allCases) { count in + Button { + selection = count + } label: { + VStack(spacing: Design.Spacing.xxSmall) { + Text("\(count.rawValue)") + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) + Text(count.rawValue == 1 ? "deck" : "decks") + .font(.system(size: Design.BaseFontSize.xSmall)) + } + .foregroundStyle(selection == count ? .black : .white) + .frame(maxWidth: .infinity) + .padding(.vertical, Design.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.small) + .fill(selection == count ? Color.Settings.accent : Color.white.opacity(Design.Opacity.subtle)) + ) + } + .buttonStyle(.plain) + } + } + } +} + +// MARK: - Table Limits Picker + +struct TableLimitsPicker: View { + @Binding var selection: TableLimits + + var body: some View { + VStack(spacing: Design.Spacing.small) { + ForEach(TableLimits.allCases) { limit in + Button { + selection = limit + } label: { + HStack { + VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + Text(limit.displayName) + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .foregroundStyle(.white) + + Text(limit.description) + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + + Spacer() + + if selection == limit { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.Settings.accent) + } + } + .padding(.vertical, Design.Spacing.small) + } + .buttonStyle(.plain) + + if limit != TableLimits.allCases.last { + Divider().background(Color.white.opacity(Design.Opacity.hint)) + } + } + } + } +} + +#Preview { + SettingsView(settings: GameSettings(), gameState: nil) +} + diff --git a/Blackjack/Views/StatisticsSheetView.swift b/Blackjack/Views/StatisticsSheetView.swift new file mode 100644 index 0000000..7abf3c1 --- /dev/null +++ b/Blackjack/Views/StatisticsSheetView.swift @@ -0,0 +1,228 @@ +// +// StatisticsSheetView.swift +// Blackjack +// +// Game statistics and history. +// + +import SwiftUI +import CasinoKit + +struct StatisticsSheetView: View { + let state: GameState + + @Environment(\.dismiss) private var dismiss + + // MARK: - Computed Stats + + private var totalRounds: Int { + state.roundHistory.count + } + + private var wins: Int { + state.roundHistory.filter { $0.mainHandResult.isWin }.count + } + + private var losses: Int { + state.roundHistory.filter { + $0.mainHandResult == .lose || $0.mainHandResult == .bust + }.count + } + + private var pushes: Int { + state.roundHistory.filter { $0.mainHandResult == .push }.count + } + + private var blackjacks: Int { + state.roundHistory.filter { $0.mainHandResult == .blackjack }.count + } + + private var busts: Int { + state.roundHistory.filter { $0.mainHandResult == .bust }.count + } + + private var surrenders: Int { + state.roundHistory.filter { $0.mainHandResult == .surrender }.count + } + + private var winRate: Double { + guard totalRounds > 0 else { return 0 } + return Double(wins) / Double(totalRounds) * 100 + } + + private var totalWinnings: Int { + state.roundHistory.reduce(0) { $0 + $1.totalWinnings } + } + + private var biggestWin: Int { + state.roundHistory.map { $0.totalWinnings }.filter { $0 > 0 }.max() ?? 0 + } + + private var biggestLoss: Int { + state.roundHistory.map { $0.totalWinnings }.filter { $0 < 0 }.min() ?? 0 + } + + var body: some View { + SheetContainerView( + title: String(localized: "Statistics"), + content: { + // Session Summary + SheetSection(title: String(localized: "SESSION SUMMARY"), icon: "chart.bar.fill") { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: Design.Spacing.medium) { + StatBox(title: String(localized: "Rounds"), value: "\(totalRounds)", color: .white) + StatBox(title: String(localized: "Win Rate"), value: formatPercent(winRate), color: winRate >= 50 ? .green : .orange) + StatBox(title: String(localized: "Net"), value: formatMoney(totalWinnings), color: totalWinnings >= 0 ? .green : .red) + StatBox(title: String(localized: "Balance"), value: "$\(state.balance)", color: Color.Settings.accent) + } + } + + // Win Distribution + SheetSection(title: String(localized: "OUTCOMES"), icon: "chart.pie.fill") { + VStack(spacing: Design.Spacing.small) { + OutcomeRow(label: String(localized: "Blackjacks"), count: blackjacks, total: totalRounds, color: .yellow) + OutcomeRow(label: String(localized: "Wins"), count: wins - blackjacks, total: totalRounds, color: .green) + OutcomeRow(label: String(localized: "Pushes"), count: pushes, total: totalRounds, color: .blue) + OutcomeRow(label: String(localized: "Losses"), count: losses - busts, total: totalRounds, color: .orange) + OutcomeRow(label: String(localized: "Busts"), count: busts, total: totalRounds, color: .red) + if surrenders > 0 { + OutcomeRow(label: String(localized: "Surrenders"), count: surrenders, total: totalRounds, color: .gray) + } + } + } + + // Biggest Swings + if totalRounds > 0 { + SheetSection(title: String(localized: "BIGGEST SWINGS"), icon: "arrow.up.arrow.down") { + HStack(spacing: Design.Spacing.large) { + VStack(spacing: Design.Spacing.xSmall) { + Text(String(localized: "Best")) + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + Text(formatMoney(biggestWin)) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded)) + .foregroundStyle(.green) + } + .frame(maxWidth: .infinity) + + Divider() + .frame(height: 40) + .background(Color.white.opacity(Design.Opacity.hint)) + + VStack(spacing: Design.Spacing.xSmall) { + Text(String(localized: "Worst")) + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + Text(formatMoney(biggestLoss)) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded)) + .foregroundStyle(.red) + } + .frame(maxWidth: .infinity) + } + } + } + }, + onCancel: nil, + onDone: { dismiss() }, + doneButtonText: String(localized: "Done") + ) + } + + private func formatMoney(_ amount: Int) -> String { + if amount >= 0 { + return "+$\(amount)" + } else { + return "-$\(abs(amount))" + } + } + + private func formatPercent(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(1))) + "%" + } +} + +// MARK: - Stat Box + +struct StatBox: View { + let title: String + let value: String + let color: Color + + var body: some View { + VStack(spacing: Design.Spacing.xSmall) { + Text(title) + .font(.system(size: Design.BaseFontSize.small)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Text(value) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded)) + .foregroundStyle(color) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity) + .padding(Design.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.small) + .fill(Color.white.opacity(Design.Opacity.subtle)) + ) + } +} + +// MARK: - Outcome Row + +struct OutcomeRow: View { + let label: String + let count: Int + let total: Int + let color: Color + + private var percentage: Double { + guard total > 0 else { return 0 } + return Double(count) / Double(total) * 100 + } + + private func formatPercentWhole(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(0))) + "%" + } + + var body: some View { + HStack { + // Label + Text(label) + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + + Spacer() + + // Count + Text("\(count)") + .font(.system(size: Design.BaseFontSize.body, weight: .bold)) + .foregroundStyle(color) + + // Progress bar + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall) + .fill(Color.white.opacity(Design.Opacity.subtle)) + + RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall) + .fill(color) + .frame(width: geometry.size.width * CGFloat(percentage / 100)) + } + } + .frame(width: 60, height: 8) + + // Percentage + Text(formatPercentWhole(percentage)) + .font(.system(size: Design.BaseFontSize.small, design: .rounded)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + .frame(width: 40, alignment: .trailing) + } + .padding(.vertical, Design.Spacing.xSmall) + } +} + +#Preview { + StatisticsSheetView(state: GameState(settings: GameSettings())) +} + diff --git a/BlackjackTests/BlackjackTests.swift b/BlackjackTests/BlackjackTests.swift new file mode 100644 index 0000000..ad87f58 --- /dev/null +++ b/BlackjackTests/BlackjackTests.swift @@ -0,0 +1,17 @@ +// +// BlackjackTests.swift +// BlackjackTests +// +// Created by Matt Bruce on 12/17/25. +// + +import Testing +@testable import Blackjack + +struct BlackjackTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/BlackjackUITests/BlackjackUITests.swift b/BlackjackUITests/BlackjackUITests.swift new file mode 100644 index 0000000..8a3cda9 --- /dev/null +++ b/BlackjackUITests/BlackjackUITests.swift @@ -0,0 +1,41 @@ +// +// BlackjackUITests.swift +// BlackjackUITests +// +// Created by Matt Bruce on 12/17/25. +// + +import XCTest + +final class BlackjackUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/BlackjackUITests/BlackjackUITestsLaunchTests.swift b/BlackjackUITests/BlackjackUITestsLaunchTests.swift new file mode 100644 index 0000000..14ae08f --- /dev/null +++ b/BlackjackUITests/BlackjackUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// BlackjackUITestsLaunchTests.swift +// BlackjackUITests +// +// Created by Matt Bruce on 12/17/25. +// + +import XCTest + +final class BlackjackUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/CasinoKit/Sources/CasinoKit/Views/Branding/AppIconView.swift b/CasinoKit/Sources/CasinoKit/Views/Branding/AppIconView.swift index 90b16bb..c5afd81 100644 --- a/CasinoKit/Sources/CasinoKit/Views/Branding/AppIconView.swift +++ b/CasinoKit/Sources/CasinoKit/Views/Branding/AppIconView.swift @@ -45,7 +45,9 @@ public struct AppIconConfig: Sendable { public static let blackjack = AppIconConfig( title: "BLACKJACK", subtitle: "21", - iconSymbol: "suit.club.fill" + iconSymbol: "suit.club.fill", + primaryColor: Color(red: 0.05, green: 0.35, blue: 0.15), + secondaryColor: Color(red: 0.03, green: 0.2, blue: 0.1) ) /// Poker game icon configuration. diff --git a/CasinoKit/Sources/CasinoKit/Views/Branding/LaunchScreenView.swift b/CasinoKit/Sources/CasinoKit/Views/Branding/LaunchScreenView.swift index f5d8743..6f675be 100644 --- a/CasinoKit/Sources/CasinoKit/Views/Branding/LaunchScreenView.swift +++ b/CasinoKit/Sources/CasinoKit/Views/Branding/LaunchScreenView.swift @@ -52,7 +52,9 @@ public struct LaunchScreenConfig: Sendable { title: "BLACKJACK", subtitle: "21", tagline: "Beat the Dealer", - iconSymbols: ["suit.club.fill", "suit.diamond.fill"] + iconSymbols: ["suit.club.fill", "suit.diamond.fill"], + primaryColor: Color(red: 0.05, green: 0.35, blue: 0.15), + secondaryColor: Color(red: 0.03, green: 0.2, blue: 0.1) ) /// Poker game launch screen configuration.