// // SoundManager.swift // CasinoKit // // Manages game sound effects for casino games. // import AVFoundation import AudioToolbox import SwiftUI /// Types of sound effects used in casino games. public enum GameSound: String, CaseIterable, Sendable { case chipPlace = "chip_place" // When placing a bet case chipStack = "chip_stack" // Stacking multiple chips case cardDeal = "card_deal" // Dealing a card case cardFlip = "card_flip" // Flipping a card face up case cardShuffle = "card_shuffle" // Shuffling the deck case win = "win" // Player wins case lose = "lose" // Player loses case push = "push" // Tie/push result case bigWin = "big_win" // Large payout case buttonTap = "button_tap" // UI button tap case newRound = "new_round" // Starting a new round case clearBets = "clear_bets" // Clearing all bets case gameOver = "game_over" // Out of chips / game over /// File extension for the sound file. public var fileExtension: String { "mp3" } /// System sound ID to use as fallback when custom sound file is missing. /// These are built-in iOS sounds that approximate the intended effect. public var fallbackSystemSound: SystemSoundID { switch self { case .chipPlace: return 1104 // Key press click case .chipStack: return 1105 // Keyboard key case .cardDeal: return 1306 // Swoosh case .cardFlip: return 1104 // Click case .cardShuffle: return 1110 // Swish case .win: return 1025 // Success chime case .lose: return 1053 // Error tone case .push: return 1057 // Neutral beep case .bigWin: return 1026 // Fanfare case .buttonTap: return 1104 // Tap case .newRound: return 1113 // Begin case .clearBets: return 1155 // Sweep case .gameOver: return 1073 // Sad trombone / failure } } /// Display name for settings. public var displayName: String { switch self { case .chipPlace: return "Chip Place" case .chipStack: return "Chip Stack" case .cardDeal: return "Card Deal" case .cardFlip: return "Card Flip" case .cardShuffle: return "Card Shuffle" case .win: return "Win" case .lose: return "Lose" case .push: return "Push/Tie" case .bigWin: return "Big Win" case .buttonTap: return "Button Tap" case .newRound: return "New Round" case .clearBets: return "Clear Bets" case .gameOver: return "Game Over" } } } /// 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 @Observable public final class SoundManager { // MARK: - Singleton public static let shared = SoundManager() // MARK: - Properties /// Whether sound effects are enabled. public var soundEnabled: Bool = true { didSet { 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). public var volume: Float = 1.0 { didSet { UserDefaults.standard.set(volume, forKey: "casinokit.soundVolume") updatePlayerVolumes() } } /// The bundle to load sound files from. Defaults to CasinoKit's bundle. /// Set this to `.main` if sounds are in your app bundle instead. public var soundBundle: Bundle = .module /// Cache of audio players for quick playback. private var audioPlayers: [GameSound: AVAudioPlayer] = [:] // MARK: - Initialization private init() { // Load saved preferences soundEnabled = UserDefaults.standard.object(forKey: "casinokit.soundEnabled") as? Bool ?? true hapticsEnabled = UserDefaults.standard.object(forKey: "casinokit.hapticsEnabled") as? Bool ?? true volume = UserDefaults.standard.object(forKey: "casinokit.soundVolume") as? Float ?? 1.0 // Configure audio session configureAudioSession() // Preload sounds preloadSounds() } // MARK: - Audio Session private func configureAudioSession() { do { try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) try AVAudioSession.sharedInstance().setActive(true) } catch { print("CasinoKit: Failed to configure audio session: \(error)") } } // MARK: - Preloading /// Preloads all sound files for faster playback. /// Call this after setting `soundBundle` if you're using a custom bundle. public func preloadSounds() { audioPlayers.removeAll() for sound in GameSound.allCases { if let player = createPlayer(for: sound) { audioPlayers[sound] = player } } } private func createPlayer(for sound: GameSound) -> AVAudioPlayer? { guard let url = soundBundle.url( forResource: sound.rawValue, withExtension: sound.fileExtension ) else { // Sound file not found - will use system sound fallback return nil } do { let player = try AVAudioPlayer(contentsOf: url) player.prepareToPlay() player.volume = volume return player } catch { print("CasinoKit: Failed to create audio player for \(sound.rawValue): \(error)") return nil } } // MARK: - Playback /// Plays a sound effect. /// - Parameter sound: The sound to play. public func play(_ sound: GameSound) { guard soundEnabled else { return } // Try custom sound first if let player = audioPlayers[sound] { player.currentTime = 0 player.play() } else { // Fall back to system sound playSystemSound(sound.fallbackSystemSound) } } /// Plays a system sound by ID. private func playSystemSound(_ soundID: SystemSoundID) { // Only play if volume is above threshold guard volume > 0.1 else { return } AudioServicesPlaySystemSound(soundID) } /// Plays a sound effect with a delay. /// - Parameters: /// - sound: The sound to play. /// - delay: Delay in seconds before playing. public func play(_ sound: GameSound, delay: TimeInterval) { Task { try? await Task.sleep(for: .seconds(delay)) play(sound) } } /// Plays multiple sounds in sequence. /// - Parameters: /// - sounds: Array of sounds to play. /// - interval: Time between each sound. public func playSequence(_ sounds: [GameSound], interval: TimeInterval = 0.1) { for (index, sound) in sounds.enumerated() { play(sound, delay: TimeInterval(index) * interval) } } // MARK: - Volume private func updatePlayerVolumes() { for player in audioPlayers.values { player.volume = volume } } // MARK: - Haptics /// Plays a light haptic feedback. public func hapticLight() { guard hapticsEnabled else { return } let impact = UIImpactFeedbackGenerator(style: .light) impact.impactOccurred() } /// Plays a medium haptic feedback. public func hapticMedium() { guard hapticsEnabled else { return } let impact = UIImpactFeedbackGenerator(style: .medium) 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. public func hapticSuccess() { guard hapticsEnabled else { return } let notification = UINotificationFeedbackGenerator() notification.notificationOccurred(.success) } /// Plays an error haptic notification. public func hapticError() { guard hapticsEnabled else { return } let notification = UINotificationFeedbackGenerator() notification.notificationOccurred(.error) } /// Plays a warning haptic notification. public func hapticWarning() { guard hapticsEnabled else { return } let notification = UINotificationFeedbackGenerator() notification.notificationOccurred(.warning) } } // MARK: - Convenience Extensions public extension SoundManager { /// Plays chip placement sound with haptic. func playChipPlace() { play(.chipPlace) hapticLight() } /// Plays card deal sound. func playCardDeal() { play(.cardDeal) } /// Plays card flip sound with haptic. func playCardFlip() { play(.cardFlip) hapticLight() } /// Plays win sound with haptic. func playWin(isBigWin: Bool = false) { play(isBigWin ? .bigWin : .win) hapticSuccess() } /// Plays lose sound with haptic. func playLose() { play(.lose) hapticError() } /// Plays push/tie sound with haptic. func playPush() { play(.push) hapticMedium() } /// Plays game over sound with haptic. func playGameOver() { play(.gameOver) hapticError() } /// Plays new round sound with haptic. func playNewRound() { play(.newRound) hapticMedium() } /// Plays clear bets sound with haptic. func playClearBets() { play(.clearBets) hapticMedium() } }