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

This commit is contained in:
Matt Bruce 2025-12-17 14:10:36 -06:00
parent e2785c3a48
commit 612b04843b
7 changed files with 1150 additions and 7 deletions

467
CasinoKit/GAME_TEMPLATE.md Normal file
View File

@ -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<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!"))
```
## 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.*

View File

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

View File

@ -36,6 +36,16 @@
// MARK: - Badges
// - ValueBadge
// - ChipBadge
// MARK: - Buttons
// - ActionButton, ActionButtonStyle
// MARK: - Zones
// - BettingZone
// MARK: - Cards (additional)
// - HandDisplayView
// MARK: - Settings
// - SettingsToggle

View File

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

View File

@ -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) { }
}
}
}

View File

@ -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..<min(2, maxCards), id: \.self) { _ in
CardPlaceholderView(width: cardWidth)
}
} else {
ForEach(cards.indices, id: \.self) { index in
let isFaceUp = index < cardsFaceUp.count ? cardsFaceUp[index] : true
CardView(
card: cards[index],
isFaceUp: isFaceUp,
cardWidth: cardWidth
)
.zIndex(Double(index))
}
}
}
}
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.small)
.strokeBorder(
isWinner ? Color.yellow : Color.clear,
lineWidth: CasinoDesign.LineWidth.medium
)
)
.overlay(alignment: .bottom) {
if isWinner {
Text(String(localized: "WIN", bundle: .module))
.font(.system(size: winBadgeFontSize, weight: .black))
.foregroundStyle(.black)
.padding(.horizontal, CasinoDesign.Spacing.small)
.padding(.vertical, CasinoDesign.Spacing.xxSmall)
.background(
Capsule()
.fill(Color.yellow)
)
.offset(y: CasinoDesign.Spacing.small)
}
}
}
}
}
#Preview {
ZStack {
Color.CasinoTable.felt.ignoresSafeArea()
HStack(spacing: 40) {
HandDisplayView(
cards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .spades, rank: .king)
],
cardsFaceUp: [true, true],
isWinner: true,
label: "PLAYER",
value: 21,
valueColor: .blue
)
HandDisplayView(
cards: [
Card(suit: .diamonds, rank: .seven),
Card(suit: .clubs, rank: .ten)
],
cardsFaceUp: [true, false],
label: "DEALER",
value: 17,
valueColor: .red
)
}
}
}

View File

@ -0,0 +1,168 @@
//
// BettingZone.swift
// CasinoKit
//
// A reusable betting zone for casino table layouts.
//
import SwiftUI
/// A tappable betting zone with label and chip display.
public struct BettingZone: View {
/// The zone label (e.g., "PLAYER", "TIE", "INSURANCE").
public let label: String
/// Optional payout info (e.g., "1:1", "8:1").
public let payoutInfo: String?
/// Current bet amount (0 if no bet).
public let betAmount: Int
/// Whether the zone is enabled for betting.
public let isEnabled: Bool
/// Action when the zone is tapped.
public let onTap: () -> 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)
}
}
}