diff --git a/CasinoKit/GAME_TEMPLATE.md b/CasinoKit/GAME_TEMPLATE.md new file mode 100644 index 0000000..b260366 --- /dev/null +++ b/CasinoKit/GAME_TEMPLATE.md @@ -0,0 +1,467 @@ +# Casino Game Development Guide + +This guide explains how to build a new casino card game (like Blackjack, Poker, etc.) using CasinoKit, following the patterns established in the Baccarat app. + +## Project Structure + +``` +YourGame/ +├── YourGameApp.swift # App entry point +├── ContentView.swift # Root view (usually just GameTableView) +├── Engine/ +│ ├── YourGameEngine.swift # Game rules & logic +│ └── GameState.swift # Game state machine +├── Models/ +│ ├── BetType.swift # Game-specific bet types +│ ├── GameResult.swift # Win/loss/push outcomes +│ ├── GameSettings.swift # User settings (can reuse pattern) +│ ├── Hand.swift # Hand representation (if needed) +│ └── Shoe.swift # Card shoe (if multi-deck) +├── Storage/ +│ └── YourGameData.swift # Persistence model (PersistableGameData) +├── Theme/ +│ └── DesignConstants.swift # Game-specific design tokens +├── Views/ +│ ├── GameTableView.swift # Main game screen +│ ├── YourTableLayoutView.swift # Game-specific table layout +│ ├── ResultBannerView.swift # Win/loss display +│ ├── RulesHelpView.swift # Game rules explanation +│ ├── SettingsView.swift # Settings screen +│ └── StatisticsSheetView.swift # Stats display +└── Resources/ + └── Localizable.xcstrings # Translations +``` + +## What CasinoKit Provides + +### Core Components (Import `CasinoKit`) + +| Category | Components | Usage | +|----------|------------|-------| +| **Cards** | `Card`, `Suit`, `Rank`, `Deck` | Card models | +| **Card Views** | `CardView`, `CardPlaceholderView` | Card display | +| **Chips** | `ChipDenomination`, `ChipView`, `ChipStackView`, `ChipSelectorView` | Betting chips | +| **Table** | `TableBackgroundView`, `FeltPatternView` | Casino felt background | +| **Overlays** | `GameOverView`, `ConfettiView` | Game over & celebrations | +| **Top Bar** | `TopBarView` | Balance, settings, stats buttons | +| **Badges** | `ValueBadge` | Numeric value display | +| **Settings** | `SettingsToggle`, `SpeedPicker`, `VolumePicker`, `BalancePicker` | Settings UI | +| **Sheets** | `SheetContainerView`, `SheetSection` | Modal sheets | +| **Branding** | `AppIconView`, `LaunchScreenView` | App icons & splash | +| **Audio** | `SoundManager`, `GameSound` | Sound effects & haptics | +| **Storage** | `CloudSyncManager`, `PersistableGameData` | iCloud persistence | +| **Models** | `TableLimits` | Betting limits presets | +| **Design** | `CasinoDesign` | Spacing, colors, animations | + +### Using CasinoKit Components + +```swift +import SwiftUI +import CasinoKit + +struct GameTableView: View { + @State private var settings = GameSettings() + @State private var gameState: GameState? + @State private var selectedChip: ChipDenomination = .hundred + + var body: some View { + ZStack { + // 1. Table Background (from CasinoKit) + TableBackgroundView() + + VStack { + // 2. Top Bar (from CasinoKit) + TopBarView( + balance: state.balance, + secondaryInfo: "\(state.engine.shoe.cardsRemaining)", + secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill", + onReset: { state.resetGame() }, + onSettings: { showSettings = true }, + onHelp: { showRules = true }, + onStats: { showStats = true } + ) + + // 3. Your Game-Specific Table Layout + YourTableLayoutView(...) + + // 4. Chip Selector (from CasinoKit) + ChipSelectorView( + selectedChip: $selectedChip, + availableChips: ChipDenomination.allCases + ) + + // 5. Action Buttons (game-specific) + ActionButtonsView(...) + } + + // 6. Result Banner (game-specific, but follows pattern) + if state.showResultBanner { + ResultBannerView(...) + } + + // 7. Confetti for Wins (from CasinoKit) + if state.lastWinnings > 0 { + ConfettiView() + } + + // 8. Game Over (from CasinoKit) + if state.isGameOver { + GameOverView( + roundsPlayed: state.roundsPlayed, + onPlayAgain: { state.resetGame() } + ) + } + } + .sheet(isPresented: $showSettings) { + SettingsView(settings: settings, gameState: state) { ... } + } + } +} +``` + +## Game-Specific Implementation + +### 1. Game Engine (Required) + +Create your game's rule engine. This handles: +- Card dealing logic +- Hand evaluation +- Win/loss determination +- Payout calculations + +```swift +// Engine/YourGameEngine.swift +import CasinoKit + +@Observable +@MainActor +final class YourGameEngine { + var shoe: Shoe + var playerHand: [Card] = [] + var dealerHand: [Card] = [] + + init(deckCount: Int = 6) { + self.shoe = Shoe(deckCount: deckCount) + } + + func dealInitialCards() { ... } + func evaluateHand(_ cards: [Card]) -> Int { ... } + func determineWinner() -> GameResult { ... } + func calculatePayout(bet: Int, result: GameResult) -> Int { ... } +} +``` + +### 2. Game State (Required) + +Manages the state machine for your game: + +```swift +// Engine/GameState.swift +import SwiftUI +import CasinoKit + +enum GamePhase { + case betting + case dealing + case playerTurn // Blackjack-specific + case dealerTurn // Blackjack-specific + case roundComplete +} + +@Observable +@MainActor +final class GameState { + // Core state + var balance: Int + var currentBets: [BetType: Int] = [:] + var currentPhase: GamePhase = .betting + var showResultBanner = false + + // Engine + let engine: YourGameEngine + + // Persistence + private let persistence: CloudSyncManager + + // Sound + private let sound = SoundManager.shared + + init(settings: GameSettings) { + self.engine = YourGameEngine(deckCount: settings.deckCount.rawValue) + self.balance = settings.startingBalance + self.persistence = CloudSyncManager() + loadSavedGame() + } + + func placeBet(type: BetType, amount: Int) { + currentBets[type, default: 0] += amount + balance -= amount + sound.play(.chipPlace) + } + + func deal() async { + currentPhase = .dealing + sound.play(.cardDeal) + // Game-specific dealing logic + } + + func newRound() { + currentPhase = .betting + showResultBanner = false + sound.play(.newRound) + } +} +``` + +### 3. Bet Types (Game-Specific) + +```swift +// Models/BetType.swift + +enum BetType: String, CaseIterable, Identifiable { + // Blackjack example: + case main = "main" + case insurance = "insurance" + case doubleDown = "double" + case split = "split" + + // Baccarat example: + // case player, banker, tie, playerPair, bankerPair, dragonBonusPlayer, dragonBonusBanker + + var id: String { rawValue } + + var displayName: String { ... } + var payoutMultiplier: Double { ... } +} +``` + +### 4. Game Result (Game-Specific) + +```swift +// Models/GameResult.swift + +enum GameResult: Equatable { + // Blackjack example: + case playerWins + case dealerWins + case push + case blackjack + case bust + + var displayText: String { ... } + var color: Color { ... } +} +``` + +### 5. Table Layout (Game-Specific) + +This is the main visual difference between games: + +```swift +// Views/YourTableLayoutView.swift + +struct BlackjackTableView: View { + // Shows dealer hand at top, player hand(s) below + // Hit/Stand/Double/Split buttons + // Insurance betting zone +} + +struct BaccaratTableView: View { + // Shows Player/Banker/Tie betting zones + // Side bet zones (pairs, dragon bonus) +} + +struct PokerTableView: View { + // Community cards in center + // Player positions around table + // Pot display +} +``` + +### 6. Settings View (Mostly Reusable) + +```swift +// Views/SettingsView.swift +import CasinoKit + +struct SettingsView: View { + @Bindable var settings: GameSettings + let gameState: GameState + + var body: some View { + SheetContainerView(title: "Settings") { + // Table Limits (from CasinoKit pattern) + SheetSection(title: "TABLE LIMITS", icon: "banknote") { + // Use TableLimits enum from CasinoKit + } + + // Deck Settings (game-specific) + SheetSection(title: "DECK SETTINGS", icon: "rectangle.portrait.on.rectangle.portrait") { + // DeckCount options + } + + // Display Settings (reusable) + SheetSection(title: "DISPLAY", icon: "eye") { + SettingsToggle(title: "...", subtitle: "...", isOn: $settings.showX) + } + + // Sound (from CasinoKit) + SheetSection(title: "SOUND & HAPTICS", icon: "speaker.wave.2") { + SettingsToggle(...) + VolumePicker(volume: $settings.soundVolume) + } + + // iCloud Sync (pattern from Baccarat) + SheetSection(title: "CLOUD SYNC", icon: "icloud") { ... } + } + } +} +``` + +## Sound Integration + +```swift +// In your GameState +let sound = SoundManager.shared + +// Play sounds at appropriate moments: +sound.play(.chipPlace) // When placing a bet +sound.play(.cardDeal) // When dealing cards +sound.play(.cardFlip) // When flipping cards +sound.play(.win) // On player win +sound.play(.lose) // On player loss +sound.play(.push) // On tie/push +sound.play(.newRound) // Starting new round +sound.play(.gameOver) // When out of chips +``` + +## Persistence + +```swift +// Storage/YourGameData.swift +import CasinoKit + +struct BlackjackGameData: PersistableGameData { + static let gameIdentifier = "blackjack" + + var roundsPlayed: Int { roundHistory.count } + var lastModified: Date + + static var empty: BlackjackGameData { + BlackjackGameData( + lastModified: Date(), + balance: 10_000, + roundHistory: [], + totalWinnings: 0, + blackjackCount: 0, // Game-specific stat + bustCount: 0 // Game-specific stat + ) + } + + var balance: Int + var roundHistory: [SavedRoundResult] + var totalWinnings: Int + var blackjackCount: Int + var bustCount: Int +} +``` + +## Design Constants + +Extend CasinoDesign for game-specific values: + +```swift +// Theme/DesignConstants.swift + +enum Design { + // Reuse CasinoDesign values + typealias Spacing = CasinoDesign.Spacing + typealias CornerRadius = CasinoDesign.CornerRadius + typealias Animation = CasinoDesign.Animation + + // Game-specific sizes + enum Size { + static let playerCardWidth: CGFloat = 55 + static let dealerCardWidth: CGFloat = 50 + // ... game-specific dimensions + } + + // Game-specific colors (extend Color) +} + +extension Color { + enum BettingZone { + static let main = Color.blue.opacity(0.3) + static let insurance = Color.yellow.opacity(0.3) + // ... game-specific colors + } +} +``` + +## Localization + +Use String Catalogs (`.xcstrings`): + +```swift +// Game-specific strings +Text(String(localized: "Hit")) +Text(String(localized: "Stand")) +Text(String(localized: "Double Down")) +Text(String(localized: "Split")) +Text(String(localized: "Blackjack!")) +Text(String(localized: "Bust!")) +``` + +## 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 +- [ ] Test VoiceOver +- [ ] Test iPad layout +- [ ] Create app icon using `AppIconView` + +## What Could Be Added to CasinoKit + +The following patterns from Baccarat could be abstracted: + +1. **Generic ResultBannerView** - Win/loss display with bet breakdown +2. **BettingZone protocol** - Common betting zone behavior +3. **GameStateProtocol** - Common state machine patterns +4. **HandDisplayView** - Generic card hand display +5. **ActionButtonsView** - Deal/Clear/New Round pattern +6. **StatisticsView** - Generic stats display + +--- + +*This guide is based on the Baccarat implementation. For reference, see the Baccarat app structure and CasinoKit source code.* + diff --git a/CasinoKit/README.md b/CasinoKit/README.md index 1907b87..9687489 100644 --- a/CasinoKit/README.md +++ b/CasinoKit/README.md @@ -110,6 +110,135 @@ SheetSection(title: "SECTION TITLE", icon: "star.fill") { } ``` +### 🎲 Game Table Components + +**TableBackgroundView** - Casino felt background with pattern. + +```swift +TableBackgroundView() // Default casino green +TableBackgroundView(feltColor: .blue, edgeColor: .darkBlue) // Custom +``` + +**TopBarView** - Balance display and toolbar buttons. + +```swift +TopBarView( + balance: 10_500, + secondaryInfo: "411", + secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill", + onReset: { resetGame() }, + onSettings: { showSettings = true }, + onHelp: { showRules = true }, + onStats: { showStats = true } +) +``` + +**HandDisplayView** - Generic card hand display with labels. + +```swift +HandDisplayView( + cards: [card1, card2], + cardsFaceUp: [true, true], + isWinner: true, + label: "PLAYER", + value: 21, + valueColor: .blue, + maxCards: 5 // Reserve space for up to 5 cards +) +``` + +**BettingZone** - Tappable betting area with chip badge. + +```swift +BettingZone( + label: "PLAYER", + payoutInfo: "1:1", + betAmount: 500, + backgroundColor: .blue.opacity(0.2), + onTap: { placeBet(.player) } +) +``` + +**ActionButton** - Styled action buttons. + +```swift +ActionButton("Deal", icon: "play.fill", style: .primary) { deal() } +ActionButton("Clear", icon: "xmark.circle", style: .destructive) { clear() } +ActionButton("Stand", style: .secondary, isEnabled: canStand) { stand() } +``` + +### 🎉 Effects & Overlays + +**ConfettiView** - Win celebration effect. + +```swift +if playerWon { + ConfettiView() // Colorful falling confetti + ConfettiView(colors: [.yellow, .gold], count: 30) // Customized +} +``` + +**GameOverView** - Game over modal when out of chips. + +```swift +GameOverView( + roundsPlayed: 25, + additionalStats: [ + ("Biggest Win", "$5,000"), + ("Blackjacks", "3") + ], + onPlayAgain: { resetGame() } +) +``` + +**ValueBadge** - Circular numeric badge. + +```swift +ValueBadge(value: 21, color: .blue) // Hand value display +``` + +### ⚙️ Settings Components + +**SettingsToggle** - Toggle with title and subtitle. + +```swift +SettingsToggle( + title: "Sound Effects", + subtitle: "Chips, cards, and results", + isOn: $settings.soundEnabled +) +``` + +**SpeedPicker** - Fast/Normal/Slow animation speed. + +```swift +SpeedPicker(speed: $settings.dealingSpeed) +``` + +**VolumePicker** - Volume slider with icons. + +```swift +VolumePicker(volume: $settings.soundVolume) +``` + +**BalancePicker** - Starting balance grid. + +```swift +BalancePicker( + balance: $settings.startingBalance, + options: [1_000, 5_000, 10_000, 25_000, 50_000, 100_000] +) +``` + +**TableLimits** - Betting limit presets. + +```swift +let limits = TableLimits.medium +print(limits.minBet) // 25 +print(limits.maxBet) // 5000 +print(limits.displayName) // "Medium Stakes" +``` + ### 🎨 Branding & Icons **AppIconView** - Generate app icons with consistent styling. @@ -413,16 +542,19 @@ Screenshot the preview and add to your Assets.xcassets. CasinoKit/ ├── Package.swift ├── README.md +├── GAME_TEMPLATE.md # Guide for creating new games ├── Sources/CasinoKit/ │ ├── CasinoKit.swift │ ├── Exports.swift │ ├── Models/ │ │ ├── Card.swift │ │ ├── Deck.swift -│ │ └── ChipDenomination.swift +│ │ ├── ChipDenomination.swift +│ │ └── TableLimits.swift # Betting limit presets │ ├── Views/ │ │ ├── Cards/ -│ │ │ └── CardView.swift +│ │ │ ├── CardView.swift +│ │ │ └── HandDisplayView.swift # Generic hand display │ │ ├── Chips/ │ │ │ ├── ChipView.swift │ │ │ ├── ChipSelectorView.swift @@ -430,19 +562,37 @@ CasinoKit/ │ │ │ └── ChipOnTableView.swift │ │ ├── Sheets/ │ │ │ └── SheetContainerView.swift -│ │ └── Branding/ -│ │ ├── AppIconView.swift -│ │ ├── LaunchScreenView.swift -│ │ └── IconRenderer.swift +│ │ ├── Branding/ +│ │ │ ├── AppIconView.swift +│ │ │ ├── LaunchScreenView.swift +│ │ │ └── IconRenderer.swift +│ │ ├── Effects/ +│ │ │ └── ConfettiView.swift # Win celebration +│ │ ├── Overlays/ +│ │ │ └── GameOverView.swift # Game over modal +│ │ ├── Table/ +│ │ │ └── TableBackgroundView.swift # Felt background +│ │ ├── Bars/ +│ │ │ └── TopBarView.swift # Balance & toolbar +│ │ ├── Badges/ +│ │ │ └── ValueBadge.swift # Numeric badge +│ │ ├── Buttons/ +│ │ │ └── ActionButton.swift # Deal/Hit/Stand buttons +│ │ ├── Zones/ +│ │ │ └── BettingZone.swift # Tappable betting area +│ │ └── Settings/ +│ │ └── SettingsComponents.swift # Toggle, pickers │ ├── Audio/ │ │ └── SoundManager.swift +│ ├── Storage/ +│ │ └── CloudSyncManager.swift # iCloud persistence │ ├── Theme/ │ │ ├── CasinoTheme.swift │ │ └── CasinoDesign.swift │ └── Resources/ │ ├── Localizable.xcstrings │ └── Sounds/ -│ └── README.md (+ .mp3 files) +│ └── (audio files) └── Tests/CasinoKitTests/ └── CasinoKitTests.swift ``` diff --git a/CasinoKit/Sources/CasinoKit/Exports.swift b/CasinoKit/Sources/CasinoKit/Exports.swift index 488a107..ffbc759 100644 --- a/CasinoKit/Sources/CasinoKit/Exports.swift +++ b/CasinoKit/Sources/CasinoKit/Exports.swift @@ -36,6 +36,16 @@ // MARK: - Badges // - ValueBadge +// - ChipBadge + +// MARK: - Buttons +// - ActionButton, ActionButtonStyle + +// MARK: - Zones +// - BettingZone + +// MARK: - Cards (additional) +// - HandDisplayView // MARK: - Settings // - SettingsToggle diff --git a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings index 4cbbdd2..4312230 100644 --- a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings +++ b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings @@ -1099,6 +1099,28 @@ } } } + }, + "WIN" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "WIN" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "GANÓ" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "GAGNÉ" + } + } + } } }, "version" : "1.1" diff --git a/CasinoKit/Sources/CasinoKit/Views/Buttons/ActionButton.swift b/CasinoKit/Sources/CasinoKit/Views/Buttons/ActionButton.swift new file mode 100644 index 0000000..605b7ea --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/Buttons/ActionButton.swift @@ -0,0 +1,132 @@ +// +// ActionButton.swift +// CasinoKit +// +// Reusable action buttons for casino games (Deal, Hit, Stand, etc.). +// + +import SwiftUI + +/// A styled action button for casino games. +public struct ActionButton: View { + /// The button title. + public let title: String + + /// Optional SF Symbol icon. + public let icon: String? + + /// Button style variant. + public let style: ActionButtonStyle + + /// Whether the button is enabled. + public let isEnabled: Bool + + /// The action to perform. + public let action: () -> Void + + // Font sizes + private let fontSize: CGFloat = 18 + private let iconSize: CGFloat = 20 + + /// Creates an action button. + /// - Parameters: + /// - title: The button title. + /// - icon: Optional SF Symbol name. + /// - style: The button style. + /// - isEnabled: Whether enabled. + /// - action: The action to perform. + public init( + _ title: String, + icon: String? = nil, + style: ActionButtonStyle = .primary, + isEnabled: Bool = true, + action: @escaping () -> Void + ) { + self.title = title + self.icon = icon + self.style = style + self.isEnabled = isEnabled + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack(spacing: CasinoDesign.Spacing.small) { + if let icon = icon { + Image(systemName: icon) + .font(.system(size: iconSize, weight: .semibold)) + } + Text(title) + .font(.system(size: fontSize, weight: .bold)) + } + .foregroundStyle(style.foregroundColor) + .padding(.horizontal, CasinoDesign.Spacing.xxLarge) + .padding(.vertical, CasinoDesign.Spacing.medium) + .background(style.background) + .shadow(color: style.shadowColor, radius: CasinoDesign.Shadow.radiusMedium) + } + .disabled(!isEnabled) + .opacity(isEnabled ? 1.0 : CasinoDesign.Opacity.medium) + } +} + +/// Style variants for action buttons. +public enum ActionButtonStyle { + /// Primary gold button (Deal, Hit, etc.) + case primary + /// Destructive red button (Clear, Fold, etc.) + case destructive + /// Secondary subtle button + case secondary + + var foregroundColor: Color { + switch self { + case .primary: return .black + case .destructive: return .white + case .secondary: return .white + } + } + + @ViewBuilder + var background: some View { + switch self { + case .primary: + Capsule() + .fill( + LinearGradient( + colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark], + startPoint: .top, + endPoint: .bottom + ) + ) + case .destructive: + Capsule() + .fill(Color.CasinoButton.destructive) + case .secondary: + Capsule() + .fill(Color.white.opacity(CasinoDesign.Opacity.hint)) + } + } + + var shadowColor: Color { + switch self { + case .primary: return .yellow.opacity(CasinoDesign.Opacity.light) + case .destructive: return .red.opacity(CasinoDesign.Opacity.light) + case .secondary: return .clear + } + } +} + +#Preview { + ZStack { + Color.CasinoTable.felt.ignoresSafeArea() + + VStack(spacing: 20) { + ActionButton("Deal", icon: "play.fill", style: .primary) { } + ActionButton("Clear", icon: "xmark.circle", style: .destructive) { } + ActionButton("Stand", style: .secondary) { } + ActionButton("Hit", style: .primary, isEnabled: false) { } + } + } +} + diff --git a/CasinoKit/Sources/CasinoKit/Views/Cards/HandDisplayView.swift b/CasinoKit/Sources/CasinoKit/Views/Cards/HandDisplayView.swift new file mode 100644 index 0000000..835ad61 --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/Cards/HandDisplayView.swift @@ -0,0 +1,194 @@ +// +// HandDisplayView.swift +// CasinoKit +// +// A generic view for displaying a hand of cards with optional overlap. +// + +import SwiftUI + +/// A view displaying a hand of cards with configurable layout. +public struct HandDisplayView: View { + /// The cards in the hand. + public let cards: [Card] + + /// Which cards are face up (by index). + public let cardsFaceUp: [Bool] + + /// The width of each card. + public let cardWidth: CGFloat + + /// The overlap between cards (negative = overlap, positive = gap). + public let cardSpacing: CGFloat + + /// Whether this hand is the winner. + public let isWinner: Bool + + /// Optional label to show (e.g., "PLAYER", "DEALER"). + public let label: String? + + /// Optional value badge to show. + public let value: Int? + + /// Badge color for the value. + public let valueColor: Color + + /// Maximum number of card slots to reserve space for. + public let maxCards: Int + + // Layout + @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = 14 + @ScaledMetric(relativeTo: .caption) private var winBadgeFontSize: CGFloat = 10 + + /// Creates a hand display view. + /// - Parameters: + /// - cards: The cards to display. + /// - cardsFaceUp: Which cards are face up. + /// - cardWidth: Width of each card. + /// - cardSpacing: Spacing between cards (negative for overlap). + /// - isWinner: Whether to show winner styling. + /// - label: Optional label above cards. + /// - value: Optional value badge to show. + /// - valueColor: Color for value badge. + /// - maxCards: Max cards to reserve space for (default: 3). + public init( + cards: [Card], + cardsFaceUp: [Bool] = [], + cardWidth: CGFloat = 45, + cardSpacing: CGFloat = -12, + isWinner: Bool = false, + label: String? = nil, + value: Int? = nil, + valueColor: Color = .blue, + maxCards: Int = 3 + ) { + self.cards = cards + self.cardsFaceUp = cardsFaceUp + self.cardWidth = cardWidth + self.cardSpacing = cardSpacing + self.isWinner = isWinner + self.label = label + self.value = value + self.valueColor = valueColor + self.maxCards = maxCards + } + + /// Card height based on aspect ratio. + private var cardHeight: CGFloat { + cardWidth * CasinoDesign.Size.cardAspectRatio + } + + /// Fixed container width based on max cards. + private var containerWidth: CGFloat { + if maxCards <= 1 { + return cardWidth + CasinoDesign.Spacing.xSmall * 2 + } + let cardsWidth = cardWidth + (cardWidth + cardSpacing) * CGFloat(maxCards - 1) + return cardsWidth + CasinoDesign.Spacing.xSmall * 2 + } + + /// Fixed container height. + private var containerHeight: CGFloat { + cardHeight + CasinoDesign.Spacing.xSmall * 2 + } + + public var body: some View { + VStack(spacing: CasinoDesign.Spacing.small) { + // Label with optional value badge + if label != nil || value != nil { + HStack(spacing: CasinoDesign.Spacing.small) { + if let label = label { + Text(label) + .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + + if let value = value, !cards.isEmpty { + ValueBadge(value: value, color: valueColor) + } + } + .frame(minHeight: CasinoDesign.Spacing.xxxLarge) + } + + // Cards container + ZStack { + // Fixed-size container + Color.clear + .frame(width: containerWidth, height: containerHeight) + + // Cards + HStack(spacing: cards.isEmpty ? CasinoDesign.Spacing.small : cardSpacing) { + if cards.isEmpty { + // Placeholders + ForEach(0.. Void + + /// Background color for the zone. + public let backgroundColor: Color + + /// Text color. + public let textColor: Color + + // Layout + @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = 16 + @ScaledMetric(relativeTo: .caption) private var payoutFontSize: CGFloat = 12 + + /// Creates a betting zone. + public init( + label: String, + payoutInfo: String? = nil, + betAmount: Int = 0, + isEnabled: Bool = true, + backgroundColor: Color = .blue.opacity(0.2), + textColor: Color = .white, + onTap: @escaping () -> Void + ) { + self.label = label + self.payoutInfo = payoutInfo + self.betAmount = betAmount + self.isEnabled = isEnabled + self.backgroundColor = backgroundColor + self.textColor = textColor + self.onTap = onTap + } + + public var body: some View { + Button(action: onTap) { + ZStack { + // Background + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium) + .fill(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium) + .strokeBorder( + textColor.opacity(CasinoDesign.Opacity.light), + lineWidth: CasinoDesign.LineWidth.thin + ) + ) + + // Content + VStack(spacing: CasinoDesign.Spacing.xxSmall) { + Text(label) + .font(.system(size: labelFontSize, weight: .bold)) + .foregroundStyle(textColor) + + if let payout = payoutInfo { + Text(payout) + .font(.system(size: payoutFontSize, weight: .medium)) + .foregroundStyle(textColor.opacity(CasinoDesign.Opacity.medium)) + } + } + + // Chip badge for bet amount + if betAmount > 0 { + ChipBadge(amount: betAmount) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(CasinoDesign.Spacing.xSmall) + } + } + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .accessibilityLabel(label) + .accessibilityValue(betAmount > 0 ? "$\(betAmount) bet" : "No bet") + .accessibilityHint(isEnabled ? "Double tap to place bet" : "Betting disabled") + } +} + +/// A small chip badge showing bet amount. +public struct ChipBadge: View { + public let amount: Int + + private let badgeSize: CGFloat = 28 + private let fontSize: CGFloat = 10 + + public init(amount: Int) { + self.amount = amount + } + + public var body: some View { + ZStack { + Circle() + .fill(Color.yellow) + .frame(width: badgeSize, height: badgeSize) + + Circle() + .strokeBorder(Color.orange, lineWidth: 2) + .frame(width: badgeSize - 4, height: badgeSize - 4) + + Text(formattedAmount) + .font(.system(size: fontSize, weight: .bold)) + .foregroundStyle(.black) + .minimumScaleFactor(0.5) + } + } + + private var formattedAmount: String { + if amount >= 1_000_000 { + return "\(amount / 1_000_000)M" + } else if amount >= 1_000 { + return "\(amount / 1_000)K" + } + return "\(amount)" + } +} + +#Preview { + ZStack { + Color.CasinoTable.felt.ignoresSafeArea() + + HStack(spacing: 20) { + BettingZone( + label: "PLAYER", + payoutInfo: "1:1", + betAmount: 0, + backgroundColor: .blue.opacity(0.2) + ) { } + .frame(width: 120, height: 80) + + BettingZone( + label: "TIE", + payoutInfo: "8:1", + betAmount: 500, + backgroundColor: .green.opacity(0.2) + ) { } + .frame(width: 80, height: 80) + + BettingZone( + label: "BANKER", + payoutInfo: "0.95:1", + betAmount: 2500, + backgroundColor: .red.opacity(0.2) + ) { } + .frame(width: 120, height: 80) + } + } +} +