Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
e2785c3a48
commit
612b04843b
467
CasinoKit/GAME_TEMPLATE.md
Normal file
467
CasinoKit/GAME_TEMPLATE.md
Normal 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.*
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -36,6 +36,16 @@
|
||||
|
||||
// MARK: - Badges
|
||||
// - ValueBadge
|
||||
// - ChipBadge
|
||||
|
||||
// MARK: - Buttons
|
||||
// - ActionButton, ActionButtonStyle
|
||||
|
||||
// MARK: - Zones
|
||||
// - BettingZone
|
||||
|
||||
// MARK: - Cards (additional)
|
||||
// - HandDisplayView
|
||||
|
||||
// MARK: - Settings
|
||||
// - SettingsToggle
|
||||
|
||||
@ -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"
|
||||
|
||||
132
CasinoKit/Sources/CasinoKit/Views/Buttons/ActionButton.swift
Normal file
132
CasinoKit/Sources/CasinoKit/Views/Buttons/ActionButton.swift
Normal 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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
194
CasinoKit/Sources/CasinoKit/Views/Cards/HandDisplayView.swift
Normal file
194
CasinoKit/Sources/CasinoKit/Views/Cards/HandDisplayView.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
168
CasinoKit/Sources/CasinoKit/Views/Zones/BettingZone.swift
Normal file
168
CasinoKit/Sources/CasinoKit/Views/Zones/BettingZone.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user