diff --git a/Baccarat/Audio/SoundManager.swift b/Baccarat/Audio/SoundManager.swift new file mode 100644 index 0000000..2e01efd --- /dev/null +++ b/Baccarat/Audio/SoundManager.swift @@ -0,0 +1,289 @@ +// +// SoundManager.swift +// Baccarat +// +// Manages game sound effects. +// + +import AVFoundation +import AudioToolbox +import SwiftUI + +/// Types of sound effects used in the game. +enum GameSound: String, CaseIterable { + 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. + 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. + 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. + 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. +@MainActor +@Observable +final class SoundManager { + + // MARK: - Singleton + + static let shared = SoundManager() + + // MARK: - Properties + + /// Whether sound effects are enabled. + var soundEnabled: Bool = true { + didSet { + UserDefaults.standard.set(soundEnabled, forKey: "soundEnabled") + } + } + + /// Master volume (0.0 to 1.0). + var volume: Float = 1.0 { + didSet { + UserDefaults.standard.set(volume, forKey: "soundVolume") + updatePlayerVolumes() + } + } + + /// Whether to use system sounds as fallback (true until custom sounds are added). + private var useSystemSoundsFallback: Bool = true + + /// Cache of audio players for quick playback. + private var audioPlayers: [GameSound: AVAudioPlayer] = [:] + + // MARK: - Initialization + + private init() { + // Load saved preferences + soundEnabled = UserDefaults.standard.object(forKey: "soundEnabled") as? Bool ?? true + volume = UserDefaults.standard.object(forKey: "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("Failed to configure audio session: \(error)") + } + } + + // MARK: - Preloading + + /// Preloads all sound files for faster playback. + private func preloadSounds() { + var foundCustomSound = false + + for sound in GameSound.allCases { + if let player = createPlayer(for: sound) { + 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? { + guard let url = Bundle.main.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("Failed to create audio player for \(sound.rawValue): \(error)") + return nil + } + } + + // MARK: - Playback + + /// Plays a sound effect. + /// - Parameter sound: The sound to play. + 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. + 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. + 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. + func hapticLight() { + let impact = UIImpactFeedbackGenerator(style: .light) + impact.impactOccurred() + } + + /// Plays a medium haptic feedback. + func hapticMedium() { + let impact = UIImpactFeedbackGenerator(style: .medium) + impact.impactOccurred() + } + + /// Plays a success haptic notification. + func hapticSuccess() { + let notification = UINotificationFeedbackGenerator() + notification.notificationOccurred(.success) + } + + /// Plays an error haptic notification. + func hapticError() { + let notification = UINotificationFeedbackGenerator() + notification.notificationOccurred(.error) + } +} + +// MARK: - Convenience Extensions + +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. + 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. + func playPush() { + play(.push) + hapticMedium() + } + + /// Plays game over sound with haptic. + func playGameOver() { + play(.gameOver) + hapticError() + } +} diff --git a/Baccarat/Engine/GameState.swift b/Baccarat/Engine/GameState.swift index 62eeead..4971e1f 100644 --- a/Baccarat/Engine/GameState.swift +++ b/Baccarat/Engine/GameState.swift @@ -51,6 +51,9 @@ final class GameState { // MARK: - Settings let settings: GameSettings + // MARK: - Sound + private let sound = SoundManager.shared + // MARK: - Game Engine private(set) var engine: BaccaratEngine @@ -224,13 +227,29 @@ final class GameState { } balance -= amount + + // Play chip placement sound + if settings.soundEnabled { + sound.play(.chipPlace) + } + if settings.hapticsEnabled { + sound.hapticLight() + } } /// Clears all current bets and returns the amounts to balance. func clearBets() { - guard canPlaceBet else { return } + guard canPlaceBet, !currentBets.isEmpty else { return } balance += totalBetAmount currentBets = [] + + // Play clear bets sound + if settings.soundEnabled { + sound.play(.clearBets) + } + if settings.hapticsEnabled { + sound.hapticMedium() + } } /// Undoes the last bet placed. @@ -270,6 +289,11 @@ final class GameState { for (index, card) in initialCards.enumerated() { try? await Task.sleep(for: dealDelay) + // Play card deal sound + if settings.soundEnabled { + sound.play(.cardDeal) + } + if index % 2 == 0 { visiblePlayerCards.append(card) playerCardsFaceUp.append(false) @@ -282,6 +306,11 @@ final class GameState { // Brief pause then flip cards try? await Task.sleep(for: flipDelay) + // Play card flip sound + if settings.soundEnabled { + sound.play(.cardFlip) + } + // Flip all cards face up for i in 0.. 0 { + // Determine if it's a big win (>= 5x any bet amount or >= 500) + let maxBetAmount = currentBets.map(\.amount).max() ?? 0 + let isBigWin = totalWinnings >= maxBetAmount * 5 || totalWinnings >= 500 + if settings.soundEnabled { + sound.play(isBigWin ? .bigWin : .win) + } + if settings.hapticsEnabled { + sound.hapticSuccess() + } + } else if totalWinnings < 0 { + if settings.soundEnabled { + sound.play(.lose) + } + if settings.hapticsEnabled { + sound.hapticError() + } + } else { + // Push (tie with main bet push) + if settings.soundEnabled { + sound.play(.push) + } + if settings.hapticsEnabled { + sound.hapticMedium() + } + } + // Record result in history roundHistory.append(RoundResult( result: result, @@ -400,6 +469,11 @@ final class GameState { func newRound() { guard currentPhase == .roundComplete else { return } + // Play new round sound + if settings.soundEnabled { + sound.play(.newRound) + } + // Dismiss result banner showResultBanner = false @@ -443,6 +517,14 @@ final class GameState { playerHadPair = false bankerHadPair = false betResults = [] + + // Play new game sound + if settings.soundEnabled { + sound.play(.newRound) + } + if settings.hapticsEnabled { + sound.hapticMedium() + } } /// Applies new settings (call after settings change). diff --git a/Baccarat/Models/GameSettings.swift b/Baccarat/Models/GameSettings.swift index a19b02e..5974f36 100644 --- a/Baccarat/Models/GameSettings.swift +++ b/Baccarat/Models/GameSettings.swift @@ -133,6 +133,17 @@ final class GameSettings { /// Whether to show the history road map. var showHistory: Bool = true + // MARK: - Sound Settings + + /// Whether sound effects are enabled. + var soundEnabled: Bool = true + + /// Whether haptic feedback is enabled. + var hapticsEnabled: Bool = true + + /// Volume level for sound effects (0.0 to 1.0). + var soundVolume: Float = 1.0 + // MARK: - Persistence Keys private enum Keys { @@ -143,6 +154,9 @@ final class GameSettings { static let dealingSpeed = "settings.dealingSpeed" static let showCardsRemaining = "settings.showCardsRemaining" static let showHistory = "settings.showHistory" + static let soundEnabled = "settings.soundEnabled" + static let hapticsEnabled = "settings.hapticsEnabled" + static let soundVolume = "settings.soundVolume" } // MARK: - Initialization @@ -186,6 +200,18 @@ final class GameSettings { if defaults.object(forKey: Keys.showHistory) != nil { self.showHistory = defaults.bool(forKey: Keys.showHistory) } + + if defaults.object(forKey: Keys.soundEnabled) != nil { + self.soundEnabled = defaults.bool(forKey: Keys.soundEnabled) + } + + if defaults.object(forKey: Keys.hapticsEnabled) != nil { + self.hapticsEnabled = defaults.bool(forKey: Keys.hapticsEnabled) + } + + if let volume = defaults.object(forKey: Keys.soundVolume) as? Float { + self.soundVolume = volume + } } /// Saves settings to UserDefaults. @@ -198,6 +224,9 @@ final class GameSettings { defaults.set(dealingSpeed, forKey: Keys.dealingSpeed) defaults.set(showCardsRemaining, forKey: Keys.showCardsRemaining) defaults.set(showHistory, forKey: Keys.showHistory) + defaults.set(soundEnabled, forKey: Keys.soundEnabled) + defaults.set(hapticsEnabled, forKey: Keys.hapticsEnabled) + defaults.set(soundVolume, forKey: Keys.soundVolume) } /// Resets all settings to defaults. @@ -209,6 +238,9 @@ final class GameSettings { dealingSpeed = 1.0 showCardsRemaining = true showHistory = true + soundEnabled = true + hapticsEnabled = true + soundVolume = 1.0 save() } } diff --git a/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Resources/Localizable.xcstrings index 53e85de..bb4942b 100644 --- a/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Resources/Localizable.xcstrings @@ -46,6 +46,10 @@ "comment" : "A numbered list item with a callout number and accompanying text. The first argument is the number of the item. The second argument is the text of the item.", "isCommentAutoGenerated" : true }, + "%lld%%" : { + "comment" : "A label showing the current volume percentage. The argument is the current volume as a percentage (e.g. \"50%\").", + "isCommentAutoGenerated" : true + }, "%lldpx" : { "comment" : "A text label displaying the size of the app icon. The argument is the size of the icon in pixels.", "isCommentAutoGenerated" : true @@ -412,6 +416,88 @@ "comment" : "A section header that suggests using an online tool to generate app icon sizes.", "isCommentAutoGenerated" : true }, + "Animate dealing and flipping" : { + "comment" : "Subtitle for card animations toggle.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animate dealing and flipping" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animar reparto y volteo" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animar reparto y volteo" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animar reparto y volteo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animer la distribution et le retournement" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animer la distribution et le retournement" + } + } + } + }, + "ANIMATIONS" : { + "comment" : "Section header for animation settings.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "ANIMATIONS" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ANIMACIONES" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "ANIMACIONES" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "ANIMACIONES" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ANIMATIONS" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "ANIMATIONS" + } + } + } + }, "App Icon" : { "comment" : "A label displayed above the preview of the app icon.", "isCommentAutoGenerated" : true @@ -998,6 +1084,47 @@ } } }, + "Card Animations" : { + "comment" : "Toggle label for card animation setting.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Card Animations" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animaciones de Cartas" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animaciones de Cartas" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animaciones de Cartas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animations des Cartes" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animations des Cartes" + } + } + } + }, "Card Values" : { "comment" : "A heading that explains the values of playing cards.", "localizations" : { @@ -1121,6 +1248,47 @@ } } }, + "Chips, cards, and result sounds" : { + "comment" : "Subtitle describing sound effects toggle.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chips, cards, and result sounds" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonidos de fichas, cartas y resultados" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonidos de fichas, cartas y resultados" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonidos de fichas, cartas y resultados" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sons des jetons, cartes et résultats" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sons des jetons, cartes et résultats" + } + } + } + }, "Clear" : { "comment" : "The label of a button that clears all current bets in the game.", "localizations" : { @@ -1285,6 +1453,170 @@ } } }, + "DECK SETTINGS" : { + "comment" : "Section header for deck configuration settings.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "DECK SETTINGS" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONFIGURACIÓN DE BARAJA" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONFIGURACIÓN DE BARAJA" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONFIGURACIÓN DE BARAJA" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "PARAMÈTRES DU JEU" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "PARAMÈTRES DU JEU" + } + } + } + }, + "DISPLAY" : { + "comment" : "Section header for display settings.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "DISPLAY" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "PANTALLA" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "PANTALLA" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "PANTALLA" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AFFICHAGE" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "AFFICHAGE" + } + } + } + }, + "Display deck counter at top" : { + "comment" : "Subtitle for show cards remaining toggle.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display deck counter at top" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar contador de baraja arriba" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar contador de baraja arriba" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar contador de baraja arriba" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher le compteur de cartes en haut" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher le compteur de cartes en haut" + } + } + } + }, + "Display result road map" : { + "comment" : "Subtitle for show history toggle.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display result road map" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar historial de resultados" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar historial de resultados" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar historial de resultados" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher l'historique des résultats" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher l'historique des résultats" + } + } + } + }, "Done" : { "comment" : "The text for a button that confirms and saves settings.", "localizations" : { @@ -1582,6 +1914,47 @@ } } }, + "Haptic Feedback" : { + "comment" : "Toggle label for haptic feedback setting.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haptic Feedback" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retroalimentación Háptica" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retroalimentación Háptica" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retroalimentación Háptica" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retour Haptique" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retour Haptique" + } + } + } + }, "Help" : { "comment" : "The label of a button that shows help information.", "localizations" : { @@ -2890,6 +3263,211 @@ } } }, + "Show Cards Remaining" : { + "comment" : "Toggle label for showing cards remaining.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Cards Remaining" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar Cartas Restantes" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar Cartas Restantes" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar Cartas Restantes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher Cartes Restantes" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher Cartes Restantes" + } + } + } + }, + "Show History" : { + "comment" : "Toggle label for showing game history.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show History" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar Historial" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar Historial" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar Historial" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher l'Historique" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher l'Historique" + } + } + } + }, + "SOUND & HAPTICS" : { + "comment" : "Section header for sound and haptic settings.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SOUND & HAPTICS" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "SONIDO Y HÁPTICOS" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "SONIDO Y HÁPTICOS" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "SONIDO Y HÁPTICOS" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "SON ET HAPTIQUE" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "SON ET HAPTIQUE" + } + } + } + }, + "Sound Effects" : { + "comment" : "Toggle label for enabling sound effects.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sound Effects" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efectos de Sonido" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efectos de Sonido" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efectos de Sonido" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effets Sonores" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effets Sonores" + } + } + } + }, + "STARTING BALANCE" : { + "comment" : "Section header for starting balance settings.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "STARTING BALANCE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "SALDO INICIAL" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "SALDO INICIAL" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "SALDO INICIAL" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "SOLDE DE DÉPART" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "SOLDE DE DÉPART" + } + } + } + }, "Statistics" : { "localizations" : { "en" : { @@ -2930,6 +3508,47 @@ } } }, + "TABLE LIMITS" : { + "comment" : "Section header for table limits settings.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "TABLE LIMITS" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "LÍMITES DE MESA" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "LÍMITES DE MESA" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "LÍMITES DE MESA" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIMITES DE TABLE" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "LIMITES DE TABLE" + } + } + } + }, "tableLimitsFormat" : { "comment" : "Format string for table limits display. First argument is min bet, second is max bet.", "extractionState" : "stale", @@ -3225,6 +3844,47 @@ "comment" : "A description of an alternative method for generating app icons.", "isCommentAutoGenerated" : true }, + "Vibration for actions and results" : { + "comment" : "Subtitle describing haptic feedback toggle.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vibration for actions and results" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vibración para acciones y resultados" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vibración para acciones y resultados" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vibración para acciones y resultados" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vibration pour les actions et résultats" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vibration pour les actions et résultats" + } + } + } + }, "View detailed game statistics" : { "comment" : "A hint that appears when hovering over the \"Statistics\" button, explaining its function.", "localizations" : { @@ -3266,6 +3926,47 @@ } } }, + "Volume" : { + "comment" : "Label for volume slider.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volume" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volumen" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volumen" + } + }, + "es-US" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volumen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volume" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volume" + } + } + } + }, "WIN" : { "comment" : "The text that appears as a badge when a player wins a hand in baccarat.", "localizations" : { diff --git a/Baccarat/Resources/Sounds/README.md b/Baccarat/Resources/Sounds/README.md new file mode 100644 index 0000000..119bb89 --- /dev/null +++ b/Baccarat/Resources/Sounds/README.md @@ -0,0 +1,86 @@ +# Sound Effects for Baccarat + +## Required Sound Files + +Add the following `.mp3` files to this folder: + +| Filename | Description | When Played | +|----------|-------------|-------------| +| `chip_place.mp3` | Chip clicking sound | When placing a bet | +| `chip_stack.mp3` | Multiple chips clinking | When stacking chips | +| `card_deal.mp3` | Card sliding/dealing sound | When each card is dealt | +| `card_flip.mp3` | Card flip/reveal sound | When cards are turned face up | +| `card_shuffle.mp3` | Deck shuffling sound | When deck is shuffled (optional) | +| `win.mp3` | Victory/success sound | When player wins | +| `lose.mp3` | Loss sound (subtle) | When player loses | +| `push.mp3` | Neutral/tie sound | When result is a tie/push | +| `big_win.mp3` | Big win celebration | For large payouts | +| `button_tap.mp3` | UI tap feedback | When buttons are tapped | +| `new_round.mp3` | Fresh start sound | When starting a new round | +| `clear_bets.mp3` | Chips sliding/removing | When clearing bets | +| `game_over.mp3` | Sad/failure sound | When player runs out of chips | + +## Free Sound Resources + +### Casino & Card Game Sounds + +1. **Freesound.org** (Free, requires attribution) + - Search: "casino chips", "card dealing", "poker" + - High quality community-contributed sounds + - https://freesound.org + +2. **Mixkit** (Free, no attribution required) + - Casino and game sound effects + - https://mixkit.co/free-sound-effects/casino/ + +3. **Zapsplat** (Free with account) + - Large library of casino sounds + - https://www.zapsplat.com + +4. **Soundsnap** (Subscription/paid) + - Professional quality sounds + - https://www.soundsnap.com + +5. **Epidemic Sound** (Subscription) + - High-quality licensed sounds + - https://www.epidemicsound.com + +### Recommended Search Terms + +- "casino chip" or "poker chip" +- "card deal" or "card slide" +- "card flip" or "card turn" +- "win jingle" or "success sound" +- "game over" or "fail sound" +- "casino ambience" (for background) + +## Tips for Good Sound Selection + +1. **Keep it subtle** - Sounds should enhance, not distract +2. **Short duration** - 0.2-1.5 seconds is ideal for most effects +3. **Consistent volume** - Normalize all sounds to similar levels +4. **High quality** - Use 44.1kHz sample rate minimum +5. **Mobile-friendly** - MP3 format, reasonable file sizes (<500KB each) + +## Implementation Notes + +- The `SoundManager` class preloads all sounds at app launch +- Missing sound files are handled gracefully (no crash) +- Volume is controllable from Settings +- Sounds respect the user's sound/haptic preferences + +## Adding to Xcode Project + +1. Add `.mp3` files to this `Sounds` folder +2. In Xcode, ensure files are included in the target +3. Build and run - sounds should work automatically + +## Testing + +Run the app and: +1. Place a bet → hear chip sound +2. Deal cards → hear deal/flip sounds +3. See result → hear win/lose/push sound +4. New round → hear new round sound +5. Clear bets → hear clear sound + diff --git a/Baccarat/Resources/Sounds/big_win.mp3 b/Baccarat/Resources/Sounds/big_win.mp3 new file mode 100644 index 0000000..b0e8632 Binary files /dev/null and b/Baccarat/Resources/Sounds/big_win.mp3 differ diff --git a/Baccarat/Resources/Sounds/button_tap.mp3 b/Baccarat/Resources/Sounds/button_tap.mp3 new file mode 100644 index 0000000..9161305 Binary files /dev/null and b/Baccarat/Resources/Sounds/button_tap.mp3 differ diff --git a/Baccarat/Resources/Sounds/card_deal.mp3 b/Baccarat/Resources/Sounds/card_deal.mp3 new file mode 100644 index 0000000..364a082 Binary files /dev/null and b/Baccarat/Resources/Sounds/card_deal.mp3 differ diff --git a/Baccarat/Resources/Sounds/card_flip.mp3 b/Baccarat/Resources/Sounds/card_flip.mp3 new file mode 100644 index 0000000..2fa7b1f Binary files /dev/null and b/Baccarat/Resources/Sounds/card_flip.mp3 differ diff --git a/Baccarat/Resources/Sounds/card_shuffle.mp3 b/Baccarat/Resources/Sounds/card_shuffle.mp3 new file mode 100644 index 0000000..1f57297 Binary files /dev/null and b/Baccarat/Resources/Sounds/card_shuffle.mp3 differ diff --git a/Baccarat/Resources/Sounds/chip_place.mp3 b/Baccarat/Resources/Sounds/chip_place.mp3 new file mode 100644 index 0000000..52c70c8 Binary files /dev/null and b/Baccarat/Resources/Sounds/chip_place.mp3 differ diff --git a/Baccarat/Resources/Sounds/chip_stack.mp3 b/Baccarat/Resources/Sounds/chip_stack.mp3 new file mode 100644 index 0000000..d508a75 Binary files /dev/null and b/Baccarat/Resources/Sounds/chip_stack.mp3 differ diff --git a/Baccarat/Resources/Sounds/clear_bets.mp3 b/Baccarat/Resources/Sounds/clear_bets.mp3 new file mode 100644 index 0000000..6653281 Binary files /dev/null and b/Baccarat/Resources/Sounds/clear_bets.mp3 differ diff --git a/Baccarat/Resources/Sounds/game_over.mp3 b/Baccarat/Resources/Sounds/game_over.mp3 new file mode 100644 index 0000000..9af9011 Binary files /dev/null and b/Baccarat/Resources/Sounds/game_over.mp3 differ diff --git a/Baccarat/Resources/Sounds/lose.mp3 b/Baccarat/Resources/Sounds/lose.mp3 new file mode 100644 index 0000000..7d61151 Binary files /dev/null and b/Baccarat/Resources/Sounds/lose.mp3 differ diff --git a/Baccarat/Resources/Sounds/new_round.mp3 b/Baccarat/Resources/Sounds/new_round.mp3 new file mode 100644 index 0000000..3388e20 Binary files /dev/null and b/Baccarat/Resources/Sounds/new_round.mp3 differ diff --git a/Baccarat/Resources/Sounds/push.mp3 b/Baccarat/Resources/Sounds/push.mp3 new file mode 100644 index 0000000..96b23b1 Binary files /dev/null and b/Baccarat/Resources/Sounds/push.mp3 differ diff --git a/Baccarat/Resources/Sounds/win.mp3 b/Baccarat/Resources/Sounds/win.mp3 new file mode 100644 index 0000000..d56d3e9 Binary files /dev/null and b/Baccarat/Resources/Sounds/win.mp3 differ diff --git a/Baccarat/Views/GameTableView.swift b/Baccarat/Views/GameTableView.swift index 44c445e..2556184 100644 --- a/Baccarat/Views/GameTableView.swift +++ b/Baccarat/Views/GameTableView.swift @@ -118,7 +118,13 @@ struct GameTableView: View { betResults: state.betResults, playerHadPair: state.playerHadPair, bankerHadPair: state.bankerHadPair, - onNewRound: { state.newRound() } + currentBalance: state.balance, + minBet: state.minBet, + onNewRound: { state.newRound() }, + onGameOver: { + // Reset game (sound already played when banner appeared) + state.resetGame() + } ) .transition(.opacity) diff --git a/Baccarat/Views/ResultBannerView.swift b/Baccarat/Views/ResultBannerView.swift index 2c8c9b9..cc187fb 100644 --- a/Baccarat/Views/ResultBannerView.swift +++ b/Baccarat/Views/ResultBannerView.swift @@ -15,7 +15,15 @@ struct ResultBannerView: View { let betResults: [BetResult] var playerHadPair: Bool = false var bankerHadPair: Bool = false + let currentBalance: Int + let minBet: Int let onNewRound: () -> Void + let onGameOver: () -> Void + + /// Whether the player is out of money and can't continue. + private var isGameOver: Bool { + currentBalance < minBet + } @State private var showBanner = false @State private var showText = false @@ -113,30 +121,67 @@ struct ResultBannerView: View { .opacity(showTotal ? Design.Scale.normal : 0) } - // New Round button - Button { - onNewRound() - } label: { - Text("New Round") - .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) - .foregroundStyle(.black) - .padding(.horizontal, Design.Spacing.xxxLarge + Design.Spacing.small) - .padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall) - .background( - Capsule() - .fill( - LinearGradient( - colors: [Color.Button.goldLight, Color.Button.goldDark], - startPoint: .top, - endPoint: .bottom + // Game Over message or New Round button + if isGameOver { + // Game Over - show message and restart button + VStack(spacing: Design.Spacing.medium) { + Text(String(localized: "You've run out of chips!")) + .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) + .foregroundStyle(.red.opacity(Design.Opacity.heavy)) + + Button { + onGameOver() + } label: { + HStack(spacing: Design.Spacing.small) { + Image(systemName: "arrow.counterclockwise") + Text(String(localized: "Play Again")) + } + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, Design.Spacing.xxxLarge + Design.Spacing.small) + .padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall) + .background( + Capsule() + .fill( + LinearGradient( + colors: [Color.Button.goldLight, Color.Button.goldDark], + startPoint: .top, + endPoint: .bottom + ) ) - ) - ) - .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) + ) + .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) + } + } + .scaleEffect(showButton ? Design.Scale.normal : Design.Scale.shrunk) + .opacity(showButton ? Design.Scale.normal : 0) + .padding(.top, Design.Spacing.small) + } else { + // Normal - New Round button + Button { + onNewRound() + } label: { + Text(String(localized: "New Round")) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, Design.Spacing.xxxLarge + Design.Spacing.small) + .padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall) + .background( + Capsule() + .fill( + LinearGradient( + colors: [Color.Button.goldLight, Color.Button.goldDark], + startPoint: .top, + endPoint: .bottom + ) + ) + ) + .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) + } + .scaleEffect(showButton ? Design.Scale.normal : Design.Scale.shrunk) + .opacity(showButton ? Design.Scale.normal : 0) + .padding(.top, Design.Spacing.small) } - .scaleEffect(showButton ? Design.Scale.normal : Design.Scale.shrunk) - .opacity(showButton ? Design.Scale.normal : 0) - .padding(.top, Design.Spacing.small) } .padding(.horizontal, Design.Spacing.xLarge) .padding(.vertical, Design.Spacing.xxLarge) @@ -193,6 +238,14 @@ struct ResultBannerView: View { showButton = true } + // Play game over sound if out of chips (after a short delay so it doesn't overlap with lose sound) + if isGameOver { + Task { + try? await Task.sleep(for: .seconds(1)) + SoundManager.shared.play(.gameOver) + } + } + // Announce result to VoiceOver users announceResult() } @@ -388,7 +441,7 @@ struct ConfettiView: View { } } -#Preview { +#Preview("Win") { ZStack { Color.Table.preview .ignoresSafeArea() @@ -404,7 +457,31 @@ struct ConfettiView: View { ], playerHadPair: true, bankerHadPair: false, - onNewRound: {} + currentBalance: 5000, + minBet: 10, + onNewRound: {}, + onGameOver: {} + ) + } +} + +#Preview("Game Over") { + ZStack { + Color.Table.preview + .ignoresSafeArea() + + ResultBannerView( + result: .bankerWins, + totalWinnings: -1000, + betResults: [ + BetResult(type: .player, amount: 1000, payout: -1000) + ], + playerHadPair: false, + bankerHadPair: false, + currentBalance: 0, + minBet: 10, + onNewRound: {}, + onGameOver: {} ) } } diff --git a/Baccarat/Views/SettingsView.swift b/Baccarat/Views/SettingsView.swift index daf9a83..05181f4 100644 --- a/Baccarat/Views/SettingsView.swift +++ b/Baccarat/Views/SettingsView.swift @@ -21,7 +21,7 @@ struct SettingsView: View { title: String(localized: "Settings"), content: { // Table Limits Section (First!) - SheetSection(title: "TABLE LIMITS", icon: "banknote") { + SheetSection(title: String(localized: "TABLE LIMITS"), icon: "banknote") { TableLimitsPicker(selection: $settings.tableLimits) .onChange(of: settings.tableLimits) { _, _ in hasChanges = true @@ -29,7 +29,7 @@ struct SettingsView: View { } // Deck Settings Section - SheetSection(title: "DECK SETTINGS", icon: "rectangle.portrait.on.rectangle.portrait") { + SheetSection(title: String(localized: "DECK SETTINGS"), icon: "rectangle.portrait.on.rectangle.portrait") { DeckCountPicker(selection: $settings.deckCount) .onChange(of: settings.deckCount) { _, _ in hasChanges = true @@ -37,7 +37,7 @@ struct SettingsView: View { } // Starting Balance Section - SheetSection(title: "STARTING BALANCE", icon: "dollarsign.circle") { + SheetSection(title: String(localized: "STARTING BALANCE"), icon: "dollarsign.circle") { BalancePicker(balance: $settings.startingBalance) .onChange(of: settings.startingBalance) { _, _ in hasChanges = true @@ -45,10 +45,10 @@ struct SettingsView: View { } // Display Settings Section - SheetSection(title: "DISPLAY", icon: "eye") { + SheetSection(title: String(localized: "DISPLAY"), icon: "eye") { SettingsToggle( - title: "Show Cards Remaining", - subtitle: "Display deck counter at top", + title: String(localized: "Show Cards Remaining"), + subtitle: String(localized: "Display deck counter at top"), isOn: $settings.showCardsRemaining ) @@ -56,17 +56,17 @@ struct SettingsView: View { .background(Color.white.opacity(Design.Opacity.subtle)) SettingsToggle( - title: "Show History", - subtitle: "Display result road map", + title: String(localized: "Show History"), + subtitle: String(localized: "Display result road map"), isOn: $settings.showHistory ) } // Animation Settings Section - SheetSection(title: "ANIMATIONS", icon: "sparkles") { + SheetSection(title: String(localized: "ANIMATIONS"), icon: "sparkles") { SettingsToggle( - title: "Card Animations", - subtitle: "Animate dealing and flipping", + title: String(localized: "Card Animations"), + subtitle: String(localized: "Animate dealing and flipping"), isOn: $settings.showAnimations ) @@ -78,6 +78,42 @@ struct SettingsView: View { } } + // Sound & Haptics Section + SheetSection(title: String(localized: "SOUND & HAPTICS"), icon: "speaker.wave.2") { + SettingsToggle( + title: String(localized: "Sound Effects"), + subtitle: String(localized: "Chips, cards, and result sounds"), + isOn: $settings.soundEnabled + ) + .onChange(of: settings.soundEnabled) { _, newValue in + SoundManager.shared.soundEnabled = newValue + hasChanges = true + } + + if settings.soundEnabled { + Divider() + .background(Color.white.opacity(Design.Opacity.subtle)) + + VolumePicker(volume: $settings.soundVolume) + .onChange(of: settings.soundVolume) { _, newValue in + SoundManager.shared.volume = newValue + hasChanges = true + } + } + + Divider() + .background(Color.white.opacity(Design.Opacity.subtle)) + + SettingsToggle( + title: String(localized: "Haptic Feedback"), + subtitle: String(localized: "Vibration for actions and results"), + isOn: $settings.hapticsEnabled + ) + .onChange(of: settings.hapticsEnabled) { _, _ in + hasChanges = true + } + } + // Reset Button Button { settings.resetToDefaults() @@ -85,7 +121,7 @@ struct SettingsView: View { } label: { HStack { Image(systemName: "arrow.counterclockwise") - Text("Reset to Defaults") + Text(String(localized: "Reset to Defaults")) } .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) .foregroundStyle(.red.opacity(Design.Opacity.heavy)) @@ -234,7 +270,7 @@ struct SpeedPicker: View { var body: some View { VStack(alignment: .leading, spacing: Design.Spacing.small) { - Text("Dealing Speed") + Text(String(localized: "Dealing Speed")) .font(.system(size: Design.BaseFontSize.subheadline, weight: .medium)) .foregroundStyle(.white) @@ -323,6 +359,40 @@ struct TableLimitsPicker: View { } } +/// Volume slider for sound effects. +struct VolumePicker: View { + @Binding var volume: Float + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.small) { + HStack { + Text(String(localized: "Volume")) + .font(.system(size: Design.BaseFontSize.subheadline, weight: .medium)) + .foregroundStyle(.white) + + Spacer() + + Text("\(Int(volume * 100))%") + .font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + + HStack(spacing: Design.Spacing.medium) { + Image(systemName: "speaker.fill") + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Slider(value: $volume, in: 0...1, step: 0.1) + .tint(.yellow) + + Image(systemName: "speaker.wave.3.fill") + .font(.system(size: Design.BaseFontSize.body)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + } + } +} + #Preview { SettingsView(settings: GameSettings()) { } }