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

This commit is contained in:
Matt Bruce 2025-12-17 09:51:36 -06:00
parent deedc08b85
commit 7803a561ae
18 changed files with 179 additions and 101 deletions

View File

@ -140,6 +140,16 @@ final class GameState {
self.settings = settings self.settings = settings
self.engine = BaccaratEngine(deckCount: settings.deckCount.rawValue) self.engine = BaccaratEngine(deckCount: settings.deckCount.rawValue)
self.balance = settings.startingBalance self.balance = settings.startingBalance
// Sync sound settings with SoundManager
syncSoundSettings()
}
/// Syncs sound settings from GameSettings to SoundManager.
private func syncSoundSettings() {
sound.soundEnabled = settings.soundEnabled
sound.hapticsEnabled = settings.hapticsEnabled
sound.volume = settings.soundVolume
} }
// MARK: - Computed Properties for Bets // MARK: - Computed Properties for Bets
@ -228,13 +238,8 @@ final class GameState {
balance -= amount balance -= amount
// Play chip placement sound // Play chip placement sound and haptic
if settings.soundEnabled { sound.playChipPlace()
sound.play(.chipPlace)
}
if settings.hapticsEnabled {
sound.hapticLight()
}
} }
/// Clears all current bets and returns the amounts to balance. /// Clears all current bets and returns the amounts to balance.
@ -243,13 +248,8 @@ final class GameState {
balance += totalBetAmount balance += totalBetAmount
currentBets = [] currentBets = []
// Play clear bets sound // Play clear bets sound and haptic
if settings.soundEnabled { sound.playClearBets()
sound.play(.clearBets)
}
if settings.hapticsEnabled {
sound.hapticMedium()
}
} }
/// Undoes the last bet placed. /// Undoes the last bet placed.
@ -290,9 +290,7 @@ final class GameState {
try? await Task.sleep(for: dealDelay) try? await Task.sleep(for: dealDelay)
// Play card deal sound // Play card deal sound
if settings.soundEnabled { sound.playCardDeal()
sound.play(.cardDeal)
}
if index % 2 == 0 { if index % 2 == 0 {
visiblePlayerCards.append(card) visiblePlayerCards.append(card)
@ -307,9 +305,7 @@ final class GameState {
try? await Task.sleep(for: flipDelay) try? await Task.sleep(for: flipDelay)
// Play card flip sound // Play card flip sound
if settings.soundEnabled { sound.playCardFlip()
sound.play(.cardFlip)
}
// Flip all cards face up // Flip all cards face up
for i in 0..<playerCardsFaceUp.count { for i in 0..<playerCardsFaceUp.count {
@ -344,15 +340,11 @@ final class GameState {
if let playerThird = engine.drawPlayerThirdCard() { if let playerThird = engine.drawPlayerThirdCard() {
if settings.showAnimations { if settings.showAnimations {
try? await Task.sleep(for: dealDelay) try? await Task.sleep(for: dealDelay)
if settings.soundEnabled { sound.playCardDeal()
sound.play(.cardDeal)
}
visiblePlayerCards.append(playerThird) visiblePlayerCards.append(playerThird)
playerCardsFaceUp.append(false) playerCardsFaceUp.append(false)
try? await Task.sleep(for: shortDelay) try? await Task.sleep(for: shortDelay)
if settings.soundEnabled { sound.playCardFlip()
sound.play(.cardFlip)
}
playerCardsFaceUp[2] = true playerCardsFaceUp[2] = true
try? await Task.sleep(for: flipDelay) try? await Task.sleep(for: flipDelay)
} else { } else {
@ -366,15 +358,11 @@ final class GameState {
if let bankerThird = engine.drawBankerThirdCard() { if let bankerThird = engine.drawBankerThirdCard() {
if settings.showAnimations { if settings.showAnimations {
try? await Task.sleep(for: dealDelay) try? await Task.sleep(for: dealDelay)
if settings.soundEnabled { sound.playCardDeal()
sound.play(.cardDeal)
}
visibleBankerCards.append(bankerThird) visibleBankerCards.append(bankerThird)
bankerCardsFaceUp.append(false) bankerCardsFaceUp.append(false)
try? await Task.sleep(for: shortDelay) try? await Task.sleep(for: shortDelay)
if settings.soundEnabled { sound.playCardFlip()
sound.play(.cardFlip)
}
bankerCardsFaceUp[2] = true bankerCardsFaceUp[2] = true
try? await Task.sleep(for: dealDelay) try? await Task.sleep(for: dealDelay)
} else { } else {
@ -427,27 +415,12 @@ final class GameState {
// Determine if it's a big win (>= 5x any bet amount or >= 500) // Determine if it's a big win (>= 5x any bet amount or >= 500)
let maxBetAmount = currentBets.map(\.amount).max() ?? 0 let maxBetAmount = currentBets.map(\.amount).max() ?? 0
let isBigWin = totalWinnings >= maxBetAmount * 5 || totalWinnings >= 500 let isBigWin = totalWinnings >= maxBetAmount * 5 || totalWinnings >= 500
if settings.soundEnabled { sound.playWin(isBigWin: isBigWin)
sound.play(isBigWin ? .bigWin : .win)
}
if settings.hapticsEnabled {
sound.hapticSuccess()
}
} else if totalWinnings < 0 { } else if totalWinnings < 0 {
if settings.soundEnabled { sound.playLose()
sound.play(.lose)
}
if settings.hapticsEnabled {
sound.hapticError()
}
} else { } else {
// Push (tie with main bet push) // Push (tie with main bet push)
if settings.soundEnabled { sound.playPush()
sound.play(.push)
}
if settings.hapticsEnabled {
sound.hapticMedium()
}
} }
// Record result in history // Record result in history
@ -470,9 +443,7 @@ final class GameState {
guard currentPhase == .roundComplete else { return } guard currentPhase == .roundComplete else { return }
// Play new round sound // Play new round sound
if settings.soundEnabled { sound.playNewRound()
sound.play(.newRound)
}
// Dismiss result banner // Dismiss result banner
showResultBanner = false showResultBanner = false
@ -519,12 +490,7 @@ final class GameState {
betResults = [] betResults = []
// Play new game sound // Play new game sound
if settings.soundEnabled { sound.playNewRound()
sound.play(.newRound)
}
if settings.hapticsEnabled {
sound.hapticMedium()
}
} }
/// Applies new settings (call after settings change). /// Applies new settings (call after settings change).

View File

@ -148,6 +148,66 @@ let image = IconRenderer.renderAppIcon(config: .baccarat, size: 1024)
let allImages = IconRenderer.renderAllSizes(config: .baccarat) let allImages = IconRenderer.renderAllSizes(config: .baccarat)
``` ```
### 🔊 Audio & Haptics
**SoundManager** - Manages game sounds and haptic feedback.
```swift
// Access shared instance
let sound = SoundManager.shared
// Configure settings
sound.soundEnabled = true
sound.hapticsEnabled = true
sound.volume = 1.0
// Play sounds
sound.play(.chipPlace)
sound.play(.cardDeal)
sound.play(.win)
// Convenience methods (include haptics)
sound.playChipPlace() // Chip sound + light haptic
sound.playCardFlip() // Card sound + light haptic
sound.playWin() // Win sound + success haptic
sound.playLose() // Lose sound + error haptic
sound.playGameOver() // Game over sound + error haptic
```
**Available Sounds:**
| Sound | Description |
|-------|-------------|
| `.chipPlace` | Placing a bet |
| `.chipStack` | Stacking chips |
| `.cardDeal` | Dealing a card |
| `.cardFlip` | Flipping a card |
| `.cardShuffle` | Shuffling deck |
| `.win` | Player wins |
| `.lose` | Player loses |
| `.push` | Tie/push |
| `.bigWin` | Large payout |
| `.buttonTap` | UI tap |
| `.newRound` | Starting new round |
| `.clearBets` | Clearing bets |
| `.gameOver` | Out of chips |
**Custom Sound Files:**
The manager uses iOS system sounds as fallback. To use custom sounds:
1. Add `.mp3` files named: `chip_place.mp3`, `card_deal.mp3`, `win.mp3`, etc.
2. Add them to your app bundle's Resources folder
3. Files are automatically detected and used
**Haptic Methods:**
```swift
sound.hapticLight() // Light tap
sound.hapticMedium() // Medium tap
sound.hapticHeavy() // Heavy tap
sound.hapticSuccess() // Success notification
sound.hapticError() // Error notification
sound.hapticWarning() // Warning notification
```
### 🎨 Design System ### 🎨 Design System
**CasinoDesign** - Shared design constants. **CasinoDesign** - Shared design constants.
@ -306,11 +366,15 @@ CasinoKit/
│ │ ├── AppIconView.swift │ │ ├── AppIconView.swift
│ │ ├── LaunchScreenView.swift │ │ ├── LaunchScreenView.swift
│ │ └── IconRenderer.swift │ │ └── IconRenderer.swift
│ ├── Audio/
│ │ └── SoundManager.swift
│ ├── Theme/ │ ├── Theme/
│ │ ├── CasinoTheme.swift │ │ ├── CasinoTheme.swift
│ │ └── CasinoDesign.swift │ │ └── CasinoDesign.swift
│ └── Resources/ │ └── Resources/
│ └── Localizable.xcstrings │ ├── Localizable.xcstrings
│ └── Sounds/
│ └── README.md (+ .mp3 files)
└── Tests/CasinoKitTests/ └── Tests/CasinoKitTests/
└── CasinoKitTests.swift └── CasinoKitTests.swift
``` ```

View File

@ -1,16 +1,16 @@
// //
// SoundManager.swift // SoundManager.swift
// Baccarat // CasinoKit
// //
// Manages game sound effects. // Manages game sound effects for casino games.
// //
import AVFoundation import AVFoundation
import AudioToolbox import AudioToolbox
import SwiftUI import SwiftUI
/// Types of sound effects used in the game. /// Types of sound effects used in casino games.
enum GameSound: String, CaseIterable { public enum GameSound: String, CaseIterable, Sendable {
case chipPlace = "chip_place" // When placing a bet case chipPlace = "chip_place" // When placing a bet
case chipStack = "chip_stack" // Stacking multiple chips case chipStack = "chip_stack" // Stacking multiple chips
case cardDeal = "card_deal" // Dealing a card case cardDeal = "card_deal" // Dealing a card
@ -26,11 +26,11 @@ enum GameSound: String, CaseIterable {
case gameOver = "game_over" // Out of chips / game over case gameOver = "game_over" // Out of chips / game over
/// File extension for the sound file. /// File extension for the sound file.
var fileExtension: String { "mp3" } public var fileExtension: String { "mp3" }
/// System sound ID to use as fallback when custom sound file is missing. /// System sound ID to use as fallback when custom sound file is missing.
/// These are built-in iOS sounds that approximate the intended effect. /// These are built-in iOS sounds that approximate the intended effect.
var fallbackSystemSound: SystemSoundID { public var fallbackSystemSound: SystemSoundID {
switch self { switch self {
case .chipPlace: return 1104 // Key press click case .chipPlace: return 1104 // Key press click
case .chipStack: return 1105 // Keyboard key case .chipStack: return 1105 // Keyboard key
@ -49,7 +49,7 @@ enum GameSound: String, CaseIterable {
} }
/// Display name for settings. /// Display name for settings.
var displayName: String { public var displayName: String {
switch self { switch self {
case .chipPlace: return "Chip Place" case .chipPlace: return "Chip Place"
case .chipStack: return "Chip Stack" case .chipStack: return "Chip Stack"
@ -68,34 +68,48 @@ enum GameSound: String, CaseIterable {
} }
} }
/// Manages playing game sound effects. /// Manages playing game sound effects for casino games.
///
/// Use the shared instance and configure it with your app's settings:
/// ```swift
/// SoundManager.shared.soundEnabled = settings.soundEnabled
/// SoundManager.shared.volume = settings.soundVolume
/// ```
@MainActor @MainActor
@Observable @Observable
final class SoundManager { public final class SoundManager {
// MARK: - Singleton // MARK: - Singleton
static let shared = SoundManager() public static let shared = SoundManager()
// MARK: - Properties // MARK: - Properties
/// Whether sound effects are enabled. /// Whether sound effects are enabled.
var soundEnabled: Bool = true { public var soundEnabled: Bool = true {
didSet { didSet {
UserDefaults.standard.set(soundEnabled, forKey: "soundEnabled") UserDefaults.standard.set(soundEnabled, forKey: "casinokit.soundEnabled")
}
}
/// Whether haptic feedback is enabled.
public var hapticsEnabled: Bool = true {
didSet {
UserDefaults.standard.set(hapticsEnabled, forKey: "casinokit.hapticsEnabled")
} }
} }
/// Master volume (0.0 to 1.0). /// Master volume (0.0 to 1.0).
var volume: Float = 1.0 { public var volume: Float = 1.0 {
didSet { didSet {
UserDefaults.standard.set(volume, forKey: "soundVolume") UserDefaults.standard.set(volume, forKey: "casinokit.soundVolume")
updatePlayerVolumes() updatePlayerVolumes()
} }
} }
/// Whether to use system sounds as fallback (true until custom sounds are added). /// The bundle to load sound files from. Defaults to CasinoKit's bundle.
private var useSystemSoundsFallback: Bool = true /// Set this to `.main` if sounds are in your app bundle instead.
public var soundBundle: Bundle = .module
/// Cache of audio players for quick playback. /// Cache of audio players for quick playback.
private var audioPlayers: [GameSound: AVAudioPlayer] = [:] private var audioPlayers: [GameSound: AVAudioPlayer] = [:]
@ -104,8 +118,9 @@ final class SoundManager {
private init() { private init() {
// Load saved preferences // Load saved preferences
soundEnabled = UserDefaults.standard.object(forKey: "soundEnabled") as? Bool ?? true soundEnabled = UserDefaults.standard.object(forKey: "casinokit.soundEnabled") as? Bool ?? true
volume = UserDefaults.standard.object(forKey: "soundVolume") as? Float ?? 1.0 hapticsEnabled = UserDefaults.standard.object(forKey: "casinokit.hapticsEnabled") as? Bool ?? true
volume = UserDefaults.standard.object(forKey: "casinokit.soundVolume") as? Float ?? 1.0
// Configure audio session // Configure audio session
configureAudioSession() configureAudioSession()
@ -121,29 +136,26 @@ final class SoundManager {
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
try AVAudioSession.sharedInstance().setActive(true) try AVAudioSession.sharedInstance().setActive(true)
} catch { } catch {
print("Failed to configure audio session: \(error)") print("CasinoKit: Failed to configure audio session: \(error)")
} }
} }
// MARK: - Preloading // MARK: - Preloading
/// Preloads all sound files for faster playback. /// Preloads all sound files for faster playback.
private func preloadSounds() { /// Call this after setting `soundBundle` if you're using a custom bundle.
var foundCustomSound = false public func preloadSounds() {
audioPlayers.removeAll()
for sound in GameSound.allCases { for sound in GameSound.allCases {
if let player = createPlayer(for: sound) { if let player = createPlayer(for: sound) {
audioPlayers[sound] = player audioPlayers[sound] = player
foundCustomSound = true
} }
} }
// If any custom sounds are found, disable fallback for all sounds
useSystemSoundsFallback = !foundCustomSound
} }
private func createPlayer(for sound: GameSound) -> AVAudioPlayer? { private func createPlayer(for sound: GameSound) -> AVAudioPlayer? {
guard let url = Bundle.main.url( guard let url = soundBundle.url(
forResource: sound.rawValue, forResource: sound.rawValue,
withExtension: sound.fileExtension withExtension: sound.fileExtension
) else { ) else {
@ -157,7 +169,7 @@ final class SoundManager {
player.volume = volume player.volume = volume
return player return player
} catch { } catch {
print("Failed to create audio player for \(sound.rawValue): \(error)") print("CasinoKit: Failed to create audio player for \(sound.rawValue): \(error)")
return nil return nil
} }
} }
@ -166,7 +178,7 @@ final class SoundManager {
/// Plays a sound effect. /// Plays a sound effect.
/// - Parameter sound: The sound to play. /// - Parameter sound: The sound to play.
func play(_ sound: GameSound) { public func play(_ sound: GameSound) {
guard soundEnabled else { return } guard soundEnabled else { return }
// Try custom sound first // Try custom sound first
@ -190,7 +202,7 @@ final class SoundManager {
/// - Parameters: /// - Parameters:
/// - sound: The sound to play. /// - sound: The sound to play.
/// - delay: Delay in seconds before playing. /// - delay: Delay in seconds before playing.
func play(_ sound: GameSound, delay: TimeInterval) { public func play(_ sound: GameSound, delay: TimeInterval) {
Task { Task {
try? await Task.sleep(for: .seconds(delay)) try? await Task.sleep(for: .seconds(delay))
play(sound) play(sound)
@ -201,7 +213,7 @@ final class SoundManager {
/// - Parameters: /// - Parameters:
/// - sounds: Array of sounds to play. /// - sounds: Array of sounds to play.
/// - interval: Time between each sound. /// - interval: Time between each sound.
func playSequence(_ sounds: [GameSound], interval: TimeInterval = 0.1) { public func playSequence(_ sounds: [GameSound], interval: TimeInterval = 0.1) {
for (index, sound) in sounds.enumerated() { for (index, sound) in sounds.enumerated() {
play(sound, delay: TimeInterval(index) * interval) play(sound, delay: TimeInterval(index) * interval)
} }
@ -218,33 +230,51 @@ final class SoundManager {
// MARK: - Haptics // MARK: - Haptics
/// Plays a light haptic feedback. /// Plays a light haptic feedback.
func hapticLight() { public func hapticLight() {
guard hapticsEnabled else { return }
let impact = UIImpactFeedbackGenerator(style: .light) let impact = UIImpactFeedbackGenerator(style: .light)
impact.impactOccurred() impact.impactOccurred()
} }
/// Plays a medium haptic feedback. /// Plays a medium haptic feedback.
func hapticMedium() { public func hapticMedium() {
guard hapticsEnabled else { return }
let impact = UIImpactFeedbackGenerator(style: .medium) let impact = UIImpactFeedbackGenerator(style: .medium)
impact.impactOccurred() impact.impactOccurred()
} }
/// Plays a heavy haptic feedback.
public func hapticHeavy() {
guard hapticsEnabled else { return }
let impact = UIImpactFeedbackGenerator(style: .heavy)
impact.impactOccurred()
}
/// Plays a success haptic notification. /// Plays a success haptic notification.
func hapticSuccess() { public func hapticSuccess() {
guard hapticsEnabled else { return }
let notification = UINotificationFeedbackGenerator() let notification = UINotificationFeedbackGenerator()
notification.notificationOccurred(.success) notification.notificationOccurred(.success)
} }
/// Plays an error haptic notification. /// Plays an error haptic notification.
func hapticError() { public func hapticError() {
guard hapticsEnabled else { return }
let notification = UINotificationFeedbackGenerator() let notification = UINotificationFeedbackGenerator()
notification.notificationOccurred(.error) notification.notificationOccurred(.error)
} }
/// Plays a warning haptic notification.
public func hapticWarning() {
guard hapticsEnabled else { return }
let notification = UINotificationFeedbackGenerator()
notification.notificationOccurred(.warning)
}
} }
// MARK: - Convenience Extensions // MARK: - Convenience Extensions
extension SoundManager { public extension SoundManager {
/// Plays chip placement sound with haptic. /// Plays chip placement sound with haptic.
func playChipPlace() { func playChipPlace() {
@ -257,7 +287,7 @@ extension SoundManager {
play(.cardDeal) play(.cardDeal)
} }
/// Plays card flip sound. /// Plays card flip sound with haptic.
func playCardFlip() { func playCardFlip() {
play(.cardFlip) play(.cardFlip)
hapticLight() hapticLight()
@ -275,7 +305,7 @@ extension SoundManager {
hapticError() hapticError()
} }
/// Plays push/tie sound. /// Plays push/tie sound with haptic.
func playPush() { func playPush() {
play(.push) play(.push)
hapticMedium() hapticMedium()
@ -286,4 +316,17 @@ extension SoundManager {
play(.gameOver) play(.gameOver)
hapticError() hapticError()
} }
/// Plays new round sound with haptic.
func playNewRound() {
play(.newRound)
hapticMedium()
}
/// Plays clear bets sound with haptic.
func playClearBets() {
play(.clearBets)
hapticMedium()
}
} }

View File

@ -33,3 +33,7 @@
// - CasinoDesign (constants) // - CasinoDesign (constants)
// - Color.Sheet (sheet colors) // - Color.Sheet (sheet colors)
// MARK: - Audio
// - SoundManager
// - GameSound

View File

@ -1,4 +1,4 @@
# Sound Effects for Baccarat # Sound Effects for CasinoKit
## Required Sound Files ## Required Sound Files
@ -69,11 +69,12 @@ Add the following `.mp3` files to this folder:
- Volume is controllable from Settings - Volume is controllable from Settings
- Sounds respect the user's sound/haptic preferences - Sounds respect the user's sound/haptic preferences
## Adding to Xcode Project ## Adding Sound Files
1. Add `.mp3` files to this `Sounds` folder 1. Add `.mp3` files to this `Sounds` folder in CasinoKit
2. In Xcode, ensure files are included in the target 2. The Package.swift already includes Resources, so files are automatically bundled
3. Build and run - sounds should work automatically 3. Build and run - sounds should work automatically
4. If sounds are in your app bundle instead, set `SoundManager.shared.soundBundle = .main`
## Testing ## Testing