CasinoGames/CasinoKit/Sources/CasinoKit/Audio/SoundManager.swift

342 lines
11 KiB
Swift

//
// 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). This is the linear slider value.
public var volume: Float = 1.0 {
didSet {
UserDefaults.standard.set(volume, forKey: "casinokit.soundVolume")
updatePlayerVolumes()
}
}
/// Perceived volume using an exponential curve.
/// Human hearing is logarithmic, so linear volume feels wrong.
/// This curve makes 50% on the slider sound like 50% to human ears.
private var perceivedVolume: Float {
// Using a power of 3 gives a natural-feeling curve
// 0.0 -> 0.0, 0.5 -> 0.125, 1.0 -> 1.0
pow(volume, 3)
}
/// 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 = perceivedVolume
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 (system sounds can't be volume-adjusted)
guard perceivedVolume > 0.01 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 = perceivedVolume
}
}
// 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()
}
}