Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
22884fcba7
commit
7dd1edd351
@ -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 = "<group>";
|
||||
};
|
||||
EA5AD1C02EF346C50040CB90 /* BlackjackTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = BlackjackTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EA5AD1CA2EF346C50040CB90 /* BlackjackUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = BlackjackUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
EAD890AE2EF1E9CE006DBA80 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EAD890B92EF1E9CE006DBA80 /* Baccarat */,
|
||||
EAD890C72EF1E9CF006DBA80 /* BaccaratTests */,
|
||||
EAD890D12EF1E9CF006DBA80 /* BaccaratUITests */,
|
||||
EA5AD1B22EF346C40040CB90 /* Blackjack */,
|
||||
EA5AD1C02EF346C50040CB90 /* BlackjackTests */,
|
||||
EA5AD1CA2EF346C50040CB90 /* BlackjackUITests */,
|
||||
EA5AD1FF2EF34B660040CB90 /* Frameworks */,
|
||||
EAD890B82EF1E9CE006DBA80 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@ -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 = "<group>";
|
||||
@ -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;
|
||||
|
||||
@ -5,6 +5,11 @@
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>Baccarat.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>Blackjack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
|
||||
308
Blackjack/Agents.md
Normal file
308
Blackjack/Agents.md
Normal file
@ -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.
|
||||
|
||||
11
Blackjack/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
11
Blackjack/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Blackjack/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
Normal file
BIN
Blackjack/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
36
Blackjack/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
36
Blackjack/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
6
Blackjack/Assets.xcassets/Contents.json
Normal file
6
Blackjack/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
10
Blackjack/Blackjack.entitlements
Normal file
10
Blackjack/Blackjack.entitlements
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array/>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
29
Blackjack/BlackjackApp.swift
Normal file
29
Blackjack/BlackjackApp.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
Blackjack/ContentView.swift
Normal file
18
Blackjack/ContentView.swift
Normal file
@ -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()
|
||||
}
|
||||
247
Blackjack/Engine/BlackjackEngine.swift
Normal file
247
Blackjack/Engine/BlackjackEngine.swift
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
622
Blackjack/Engine/GameState.swift
Normal file
622
Blackjack/Engine/GameState.swift
Normal file
@ -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<BlackjackGameData>
|
||||
|
||||
// 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<BlackjackGameData>()
|
||||
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..<playerHands.count {
|
||||
if playerHands[i].result == nil {
|
||||
playerHands[i].result = engine.determineResult(
|
||||
playerHand: playerHands[i],
|
||||
dealerHand: dealerHand
|
||||
)
|
||||
}
|
||||
|
||||
if let result = playerHands[i].result {
|
||||
let payout = engine.calculatePayout(
|
||||
bet: playerHands[i].bet,
|
||||
result: result,
|
||||
isDoubled: playerHands[i].isDoubledDown
|
||||
)
|
||||
balance += payout
|
||||
roundWinnings += payout - playerHands[i].bet * (playerHands[i].isDoubledDown ? 2 : 1)
|
||||
|
||||
if result == .blackjack {
|
||||
wasBlackjack = true
|
||||
}
|
||||
if result == .bust {
|
||||
hadBust = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
totalWinnings += roundWinnings
|
||||
if roundWinnings > 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()
|
||||
}
|
||||
}
|
||||
|
||||
661
Blackjack/GAME_TEMPLATE.md
Normal file
661
Blackjack/GAME_TEMPLATE.md
Normal file
@ -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<YourGameData>
|
||||
|
||||
// Sound
|
||||
private let sound = SoundManager.shared
|
||||
|
||||
init(settings: GameSettings) {
|
||||
self.engine = YourGameEngine(deckCount: settings.deckCount.rawValue)
|
||||
self.balance = settings.startingBalance
|
||||
self.persistence = CloudSyncManager<YourGameData>()
|
||||
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.*
|
||||
|
||||
49
Blackjack/LaunchScreen.storyboard
Normal file
49
Blackjack/LaunchScreen.storyboard
Normal file
@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24127" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24063"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="🃏 ♠️" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="iconsLabel">
|
||||
<rect key="frame" x="147.33333333333334" y="351" width="98.666666666666657" height="50"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="50" id="iconHeight"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="40"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" misplaced="YES" text="BLACKJACK" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="titleLabel">
|
||||
<rect key="frame" x="81" y="409" width="215" height="41"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="34"/>
|
||||
<color key="textColor" red="1" green="0.84313725490196079" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
<color key="backgroundColor" red="0.050980392156862744" green="0.34901960784313724" blue="0.14901960784313725" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="iconsLabel" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="iconsCenterX"/>
|
||||
<constraint firstItem="iconsLabel" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" constant="-50" id="iconsCenterY"/>
|
||||
<constraint firstItem="titleLabel" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="titleCenterX"/>
|
||||
<constraint firstItem="titleLabel" firstAttribute="top" secondItem="iconsLabel" secondAttribute="bottom" constant="8" id="titleTopToIcons"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="52.671755725190835" y="374.64788732394368"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
41
Blackjack/Models/BetType.swift
Normal file
41
Blackjack/Models/BetType.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
75
Blackjack/Models/GameResult.swift
Normal file
75
Blackjack/Models/GameResult.swift
Normal file
@ -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
|
||||
}
|
||||
|
||||
260
Blackjack/Models/GameSettings.swift
Normal file
260
Blackjack/Models/GameSettings.swift
Normal file
@ -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<BlackjackSettingsData>()
|
||||
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<BlackjackSettingsData>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
133
Blackjack/Models/Hand.swift
Normal file
133
Blackjack/Models/Hand.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2321
Blackjack/Resources/Localizable.xcstrings
Normal file
2321
Blackjack/Resources/Localizable.xcstrings
Normal file
File diff suppressed because it is too large
Load Diff
98
Blackjack/Storage/BlackjackGameData.swift
Normal file
98
Blackjack/Storage/BlackjackGameData.swift
Normal file
@ -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
|
||||
}
|
||||
|
||||
202
Blackjack/Theme/DesignConstants.swift
Normal file
202
Blackjack/Theme/DesignConstants.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
467
Blackjack/Views/BlackjackTableView.swift
Normal file
467
Blackjack/Views/BlackjackTableView.swift
Normal file
@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
130
Blackjack/Views/BrandingPreviewView.swift
Normal file
130
Blackjack/Views/BrandingPreviewView.swift
Normal file
@ -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()
|
||||
}
|
||||
|
||||
335
Blackjack/Views/GameTableView.swift
Normal file
335
Blackjack/Views/GameTableView.swift
Normal file
@ -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()
|
||||
}
|
||||
|
||||
196
Blackjack/Views/IconGeneratorView.swift
Normal file
196
Blackjack/Views/IconGeneratorView.swift
Normal file
@ -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()
|
||||
}
|
||||
|
||||
217
Blackjack/Views/ResultBannerView.swift
Normal file
217
Blackjack/Views/ResultBannerView.swift
Normal file
@ -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: {}
|
||||
)
|
||||
}
|
||||
|
||||
186
Blackjack/Views/RulesHelpView.swift
Normal file
186
Blackjack/Views/RulesHelpView.swift
Normal file
@ -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()
|
||||
}
|
||||
|
||||
282
Blackjack/Views/SettingsView.swift
Normal file
282
Blackjack/Views/SettingsView.swift
Normal file
@ -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)
|
||||
}
|
||||
|
||||
228
Blackjack/Views/StatisticsSheetView.swift
Normal file
228
Blackjack/Views/StatisticsSheetView.swift
Normal file
@ -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()))
|
||||
}
|
||||
|
||||
17
BlackjackTests/BlackjackTests.swift
Normal file
17
BlackjackTests/BlackjackTests.swift
Normal file
@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
41
BlackjackUITests/BlackjackUITests.swift
Normal file
41
BlackjackUITests/BlackjackUITests.swift
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
33
BlackjackUITests/BlackjackUITestsLaunchTests.swift
Normal file
33
BlackjackUITests/BlackjackUITestsLaunchTests.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user