Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-17 15:00:58 -06:00
parent 22884fcba7
commit 7dd1edd351
34 changed files with 7656 additions and 2 deletions

View File

@ -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;

View File

@ -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
View 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 apps 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 taps 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`.
- Dont 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.

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View 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
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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>

View 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
}
}
}

View 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()
}

View 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")
}
}

View 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
View 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.*

View 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>

View 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
}
}

View 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
}

View 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
View 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
}
}
}

File diff suppressed because it is too large Load Diff

View 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
}

View 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)
}
}

View 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)"
}
}

View 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()
}

View 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()
}

View 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()
}

View 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: {}
)
}

View 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()
}

View 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)
}

View 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()))
}

View 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.
}
}

View 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 its 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()
}
}
}

View 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)
}
}

View File

@ -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.

View File

@ -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.