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

This commit is contained in:
Matt Bruce 2025-12-17 09:42:42 -06:00
parent d2b93a019f
commit deedc08b85
21 changed files with 1382 additions and 39 deletions

View File

@ -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()
}
}

View File

@ -51,6 +51,9 @@ final class GameState {
// MARK: - Settings // MARK: - Settings
let settings: GameSettings let settings: GameSettings
// MARK: - Sound
private let sound = SoundManager.shared
// MARK: - Game Engine // MARK: - Game Engine
private(set) var engine: BaccaratEngine private(set) var engine: BaccaratEngine
@ -224,13 +227,29 @@ final class GameState {
} }
balance -= amount 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. /// Clears all current bets and returns the amounts to balance.
func clearBets() { func clearBets() {
guard canPlaceBet else { return } guard canPlaceBet, !currentBets.isEmpty else { return }
balance += totalBetAmount balance += totalBetAmount
currentBets = [] currentBets = []
// Play clear bets sound
if settings.soundEnabled {
sound.play(.clearBets)
}
if settings.hapticsEnabled {
sound.hapticMedium()
}
} }
/// Undoes the last bet placed. /// Undoes the last bet placed.
@ -270,6 +289,11 @@ final class GameState {
for (index, card) in initialCards.enumerated() { for (index, card) in initialCards.enumerated() {
try? await Task.sleep(for: dealDelay) try? await Task.sleep(for: dealDelay)
// Play card deal sound
if settings.soundEnabled {
sound.play(.cardDeal)
}
if index % 2 == 0 { if index % 2 == 0 {
visiblePlayerCards.append(card) visiblePlayerCards.append(card)
playerCardsFaceUp.append(false) playerCardsFaceUp.append(false)
@ -282,6 +306,11 @@ final class GameState {
// Brief pause then flip cards // Brief pause then flip cards
try? await Task.sleep(for: flipDelay) try? await Task.sleep(for: flipDelay)
// Play card flip sound
if settings.soundEnabled {
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 {
playerCardsFaceUp[i] = true playerCardsFaceUp[i] = true
@ -315,9 +344,15 @@ 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.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.play(.cardFlip)
}
playerCardsFaceUp[2] = true playerCardsFaceUp[2] = true
try? await Task.sleep(for: flipDelay) try? await Task.sleep(for: flipDelay)
} else { } else {
@ -331,9 +366,15 @@ 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.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.play(.cardFlip)
}
bankerCardsFaceUp[2] = true bankerCardsFaceUp[2] = true
try? await Task.sleep(for: dealDelay) try? await Task.sleep(for: dealDelay)
} else { } else {
@ -381,6 +422,34 @@ final class GameState {
betResults = results betResults = results
lastWinnings = totalWinnings lastWinnings = totalWinnings
// Play result sound
if totalWinnings > 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 // Record result in history
roundHistory.append(RoundResult( roundHistory.append(RoundResult(
result: result, result: result,
@ -400,6 +469,11 @@ final class GameState {
func newRound() { func newRound() {
guard currentPhase == .roundComplete else { return } guard currentPhase == .roundComplete else { return }
// Play new round sound
if settings.soundEnabled {
sound.play(.newRound)
}
// Dismiss result banner // Dismiss result banner
showResultBanner = false showResultBanner = false
@ -443,6 +517,14 @@ final class GameState {
playerHadPair = false playerHadPair = false
bankerHadPair = false bankerHadPair = false
betResults = [] betResults = []
// Play new game sound
if settings.soundEnabled {
sound.play(.newRound)
}
if settings.hapticsEnabled {
sound.hapticMedium()
}
} }
/// Applies new settings (call after settings change). /// Applies new settings (call after settings change).

View File

@ -133,6 +133,17 @@ final class GameSettings {
/// Whether to show the history road map. /// Whether to show the history road map.
var showHistory: Bool = true 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 // MARK: - Persistence Keys
private enum Keys { private enum Keys {
@ -143,6 +154,9 @@ final class GameSettings {
static let dealingSpeed = "settings.dealingSpeed" static let dealingSpeed = "settings.dealingSpeed"
static let showCardsRemaining = "settings.showCardsRemaining" static let showCardsRemaining = "settings.showCardsRemaining"
static let showHistory = "settings.showHistory" static let showHistory = "settings.showHistory"
static let soundEnabled = "settings.soundEnabled"
static let hapticsEnabled = "settings.hapticsEnabled"
static let soundVolume = "settings.soundVolume"
} }
// MARK: - Initialization // MARK: - Initialization
@ -186,6 +200,18 @@ final class GameSettings {
if defaults.object(forKey: Keys.showHistory) != nil { if defaults.object(forKey: Keys.showHistory) != nil {
self.showHistory = defaults.bool(forKey: Keys.showHistory) 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. /// Saves settings to UserDefaults.
@ -198,6 +224,9 @@ final class GameSettings {
defaults.set(dealingSpeed, forKey: Keys.dealingSpeed) defaults.set(dealingSpeed, forKey: Keys.dealingSpeed)
defaults.set(showCardsRemaining, forKey: Keys.showCardsRemaining) defaults.set(showCardsRemaining, forKey: Keys.showCardsRemaining)
defaults.set(showHistory, forKey: Keys.showHistory) 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. /// Resets all settings to defaults.
@ -209,6 +238,9 @@ final class GameSettings {
dealingSpeed = 1.0 dealingSpeed = 1.0
showCardsRemaining = true showCardsRemaining = true
showHistory = true showHistory = true
soundEnabled = true
hapticsEnabled = true
soundVolume = 1.0
save() save()
} }
} }

View File

@ -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.", "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 "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" : { "%lldpx" : {
"comment" : "A text label displaying the size of the app icon. The argument is the size of the icon in pixels.", "comment" : "A text label displaying the size of the app icon. The argument is the size of the icon in pixels.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -412,6 +416,88 @@
"comment" : "A section header that suggests using an online tool to generate app icon sizes.", "comment" : "A section header that suggests using an online tool to generate app icon sizes.",
"isCommentAutoGenerated" : true "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" : { "App Icon" : {
"comment" : "A label displayed above the preview of the app icon.", "comment" : "A label displayed above the preview of the app icon.",
"isCommentAutoGenerated" : true "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" : { "Card Values" : {
"comment" : "A heading that explains the values of playing cards.", "comment" : "A heading that explains the values of playing cards.",
"localizations" : { "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" : { "Clear" : {
"comment" : "The label of a button that clears all current bets in the game.", "comment" : "The label of a button that clears all current bets in the game.",
"localizations" : { "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" : { "Done" : {
"comment" : "The text for a button that confirms and saves settings.", "comment" : "The text for a button that confirms and saves settings.",
"localizations" : { "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" : { "Help" : {
"comment" : "The label of a button that shows help information.", "comment" : "The label of a button that shows help information.",
"localizations" : { "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" : { "Statistics" : {
"localizations" : { "localizations" : {
"en" : { "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" : { "tableLimitsFormat" : {
"comment" : "Format string for table limits display. First argument is min bet, second is max bet.", "comment" : "Format string for table limits display. First argument is min bet, second is max bet.",
"extractionState" : "stale", "extractionState" : "stale",
@ -3225,6 +3844,47 @@
"comment" : "A description of an alternative method for generating app icons.", "comment" : "A description of an alternative method for generating app icons.",
"isCommentAutoGenerated" : true "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" : { "View detailed game statistics" : {
"comment" : "A hint that appears when hovering over the \"Statistics\" button, explaining its function.", "comment" : "A hint that appears when hovering over the \"Statistics\" button, explaining its function.",
"localizations" : { "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" : { "WIN" : {
"comment" : "The text that appears as a badge when a player wins a hand in baccarat.", "comment" : "The text that appears as a badge when a player wins a hand in baccarat.",
"localizations" : { "localizations" : {

View File

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -118,7 +118,13 @@ struct GameTableView: View {
betResults: state.betResults, betResults: state.betResults,
playerHadPair: state.playerHadPair, playerHadPair: state.playerHadPair,
bankerHadPair: state.bankerHadPair, 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) .transition(.opacity)

View File

@ -15,7 +15,15 @@ struct ResultBannerView: View {
let betResults: [BetResult] let betResults: [BetResult]
var playerHadPair: Bool = false var playerHadPair: Bool = false
var bankerHadPair: Bool = false var bankerHadPair: Bool = false
let currentBalance: Int
let minBet: Int
let onNewRound: () -> Void 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 showBanner = false
@State private var showText = false @State private var showText = false
@ -113,30 +121,67 @@ struct ResultBannerView: View {
.opacity(showTotal ? Design.Scale.normal : 0) .opacity(showTotal ? Design.Scale.normal : 0)
} }
// New Round button // Game Over message or New Round button
Button { if isGameOver {
onNewRound() // Game Over - show message and restart button
} label: { VStack(spacing: Design.Spacing.medium) {
Text("New Round") Text(String(localized: "You've run out of chips!"))
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) .font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.black) .foregroundStyle(.red.opacity(Design.Opacity.heavy))
.padding(.horizontal, Design.Spacing.xxxLarge + Design.Spacing.small)
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall) Button {
.background( onGameOver()
Capsule() } label: {
.fill( HStack(spacing: Design.Spacing.small) {
LinearGradient( Image(systemName: "arrow.counterclockwise")
colors: [Color.Button.goldLight, Color.Button.goldDark], Text(String(localized: "Play Again"))
startPoint: .top, }
endPoint: .bottom .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(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.xxLarge) .padding(.vertical, Design.Spacing.xxLarge)
@ -193,6 +238,14 @@ struct ResultBannerView: View {
showButton = true 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 // Announce result to VoiceOver users
announceResult() announceResult()
} }
@ -388,7 +441,7 @@ struct ConfettiView: View {
} }
} }
#Preview { #Preview("Win") {
ZStack { ZStack {
Color.Table.preview Color.Table.preview
.ignoresSafeArea() .ignoresSafeArea()
@ -404,7 +457,31 @@ struct ConfettiView: View {
], ],
playerHadPair: true, playerHadPair: true,
bankerHadPair: false, 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: {}
) )
} }
} }

View File

@ -21,7 +21,7 @@ struct SettingsView: View {
title: String(localized: "Settings"), title: String(localized: "Settings"),
content: { content: {
// Table Limits Section (First!) // Table Limits Section (First!)
SheetSection(title: "TABLE LIMITS", icon: "banknote") { SheetSection(title: String(localized: "TABLE LIMITS"), icon: "banknote") {
TableLimitsPicker(selection: $settings.tableLimits) TableLimitsPicker(selection: $settings.tableLimits)
.onChange(of: settings.tableLimits) { _, _ in .onChange(of: settings.tableLimits) { _, _ in
hasChanges = true hasChanges = true
@ -29,7 +29,7 @@ struct SettingsView: View {
} }
// Deck Settings Section // 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) DeckCountPicker(selection: $settings.deckCount)
.onChange(of: settings.deckCount) { _, _ in .onChange(of: settings.deckCount) { _, _ in
hasChanges = true hasChanges = true
@ -37,7 +37,7 @@ struct SettingsView: View {
} }
// Starting Balance Section // Starting Balance Section
SheetSection(title: "STARTING BALANCE", icon: "dollarsign.circle") { SheetSection(title: String(localized: "STARTING BALANCE"), icon: "dollarsign.circle") {
BalancePicker(balance: $settings.startingBalance) BalancePicker(balance: $settings.startingBalance)
.onChange(of: settings.startingBalance) { _, _ in .onChange(of: settings.startingBalance) { _, _ in
hasChanges = true hasChanges = true
@ -45,10 +45,10 @@ struct SettingsView: View {
} }
// Display Settings Section // Display Settings Section
SheetSection(title: "DISPLAY", icon: "eye") { SheetSection(title: String(localized: "DISPLAY"), icon: "eye") {
SettingsToggle( SettingsToggle(
title: "Show Cards Remaining", title: String(localized: "Show Cards Remaining"),
subtitle: "Display deck counter at top", subtitle: String(localized: "Display deck counter at top"),
isOn: $settings.showCardsRemaining isOn: $settings.showCardsRemaining
) )
@ -56,17 +56,17 @@ struct SettingsView: View {
.background(Color.white.opacity(Design.Opacity.subtle)) .background(Color.white.opacity(Design.Opacity.subtle))
SettingsToggle( SettingsToggle(
title: "Show History", title: String(localized: "Show History"),
subtitle: "Display result road map", subtitle: String(localized: "Display result road map"),
isOn: $settings.showHistory isOn: $settings.showHistory
) )
} }
// Animation Settings Section // Animation Settings Section
SheetSection(title: "ANIMATIONS", icon: "sparkles") { SheetSection(title: String(localized: "ANIMATIONS"), icon: "sparkles") {
SettingsToggle( SettingsToggle(
title: "Card Animations", title: String(localized: "Card Animations"),
subtitle: "Animate dealing and flipping", subtitle: String(localized: "Animate dealing and flipping"),
isOn: $settings.showAnimations 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 // Reset Button
Button { Button {
settings.resetToDefaults() settings.resetToDefaults()
@ -85,7 +121,7 @@ struct SettingsView: View {
} label: { } label: {
HStack { HStack {
Image(systemName: "arrow.counterclockwise") Image(systemName: "arrow.counterclockwise")
Text("Reset to Defaults") Text(String(localized: "Reset to Defaults"))
} }
.font(.system(size: Design.BaseFontSize.medium, weight: .medium)) .font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.red.opacity(Design.Opacity.heavy)) .foregroundStyle(.red.opacity(Design.Opacity.heavy))
@ -234,7 +270,7 @@ struct SpeedPicker: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) { VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text("Dealing Speed") Text(String(localized: "Dealing Speed"))
.font(.system(size: Design.BaseFontSize.subheadline, weight: .medium)) .font(.system(size: Design.BaseFontSize.subheadline, weight: .medium))
.foregroundStyle(.white) .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 { #Preview {
SettingsView(settings: GameSettings()) { } SettingsView(settings: GameSettings()) { }
} }