diff --git a/Baccarat/Views/ResultBannerView.swift b/Baccarat/Views/ResultBannerView.swift index 8ac6f9f..a7f3cd2 100644 --- a/Baccarat/Views/ResultBannerView.swift +++ b/Baccarat/Views/ResultBannerView.swift @@ -260,7 +260,6 @@ struct ResultBannerView: View { } .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityDescription) - .accessibilityAddTraits(.updatesFrequently) } // MARK: - Accessibility @@ -401,61 +400,7 @@ private struct PairBadge: View { } } -/// Confetti particle for celebrations. -struct ConfettiPiece: View { - let color: Color - let containerSize: CGSize - - @State private var position: CGPoint = .zero - @State private var rotation: Double = 0 - @State private var opacity: Double = 1 - - private let confettiWidth: CGFloat = 8 - private let confettiHeight: CGFloat = 12 - - var body: some View { - Rectangle() - .fill(color) - .frame(width: confettiWidth, height: confettiHeight) - .rotationEffect(.degrees(rotation)) - .position(position) - .opacity(opacity) - .onAppear { - let startX = Double.random(in: 0...containerSize.width) - position = CGPoint(x: startX, y: -20) - - withAnimation(.easeIn(duration: Double.random(in: 2...4))) { - position = CGPoint( - x: startX + Double.random(in: -100...100), - y: containerSize.height + 50 - ) - rotation = Double.random(in: 360...1080) - opacity = 0 - } - } - } -} - -/// A confetti celebration overlay. -struct ConfettiView: View { - let colors: [Color] = [.red, .blue, .green, .yellow, .orange, .purple, .pink] - - var body: some View { - GeometryReader { geometry in - ZStack { - ForEach(0..<50, id: \.self) { _ in - ConfettiPiece( - color: colors.randomElement() ?? .yellow, - containerSize: geometry.size - ) - } - } - } - .ignoresSafeArea() - .allowsHitTesting(false) - .accessibilityHidden(true) - } -} +// Note: ConfettiView is now provided by CasinoKit #Preview("Win") { ZStack { diff --git a/CasinoKit/Sources/CasinoKit/Exports.swift b/CasinoKit/Sources/CasinoKit/Exports.swift index 5a9b182..488a107 100644 --- a/CasinoKit/Sources/CasinoKit/Exports.swift +++ b/CasinoKit/Sources/CasinoKit/Exports.swift @@ -12,6 +12,7 @@ // - Card, Suit, Rank // - Deck // - ChipDenomination +// - TableLimits // MARK: - Views // - CardView, CardFrontView, CardBackView, CardPlaceholderView @@ -21,6 +22,27 @@ // - ChipStackView, ChipOnTableView // - SheetContainerView, SheetSection +// MARK: - Effects +// - ConfettiView, ConfettiPiece + +// MARK: - Overlays +// - GameOverView + +// MARK: - Table +// - TableBackgroundView, FeltPatternView + +// MARK: - Bars +// - TopBarView + +// MARK: - Badges +// - ValueBadge + +// MARK: - Settings +// - SettingsToggle +// - SpeedPicker +// - VolumePicker +// - BalancePicker + // MARK: - Branding // - AppIconView, AppIconConfig // - LaunchScreenView, LaunchScreenConfig, StaticLaunchScreenView @@ -31,7 +53,7 @@ // - DefaultCasinoTheme // - ChipColorSet // - CasinoDesign (constants) -// - Color.Sheet (sheet colors) +// - Color.Sheet, Color.Button, Color.Modal, Color.Table, Color.TopBar (colors) // MARK: - Audio // - SoundManager diff --git a/CasinoKit/Sources/CasinoKit/Models/TableLimits.swift b/CasinoKit/Sources/CasinoKit/Models/TableLimits.swift new file mode 100644 index 0000000..444edcf --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Models/TableLimits.swift @@ -0,0 +1,69 @@ +// +// TableLimits.swift +// CasinoKit +// +// Preset table limits for casino games. +// + +import Foundation + +/// Preset table limits for betting. +public enum TableLimits: String, CaseIterable, Identifiable, Sendable { + case casual = "casual" + case low = "low" + case medium = "medium" + case high = "high" + case vip = "vip" + + public var id: String { rawValue } + + /// Display name for the limit tier. + public var displayName: String { + switch self { + case .casual: return String(localized: "Casual", bundle: .module) + case .low: return String(localized: "Low Stakes", bundle: .module) + case .medium: return String(localized: "Medium Stakes", bundle: .module) + case .high: return String(localized: "High Stakes", bundle: .module) + case .vip: return String(localized: "VIP", bundle: .module) + } + } + + /// Minimum bet for this limit tier. + public var minBet: Int { + switch self { + case .casual: return 5 + case .low: return 10 + case .medium: return 25 + case .high: return 100 + case .vip: return 500 + } + } + + /// Maximum bet for this limit tier. + public var maxBet: Int { + switch self { + case .casual: return 500 + case .low: return 1_000 + case .medium: return 5_000 + case .high: return 10_000 + case .vip: return 50_000 + } + } + + /// Short description showing the bet range. + public var description: String { + "$\(minBet) - $\(maxBet.formatted())" + } + + /// Detailed description of the limit tier. + public var detailedDescription: String { + switch self { + case .casual: return String(localized: "Perfect for learning", bundle: .module) + case .low: return String(localized: "Standard mini table", bundle: .module) + case .medium: return String(localized: "Regular casino table", bundle: .module) + case .high: return String(localized: "High roller table", bundle: .module) + case .vip: return String(localized: "Exclusive VIP room", bundle: .module) + } + } +} + diff --git a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings index 97778e3..4cbbdd2 100644 --- a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings +++ b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings @@ -1,6 +1,10 @@ { "sourceLanguage" : "en", "strings" : { + "%lld" : { + "comment" : "A badge displaying a numeric value. The argument is the numeric value to display.", + "isCommentAutoGenerated" : true + }, "%lld dollar bet" : { "localizations" : { "en" : { @@ -45,10 +49,22 @@ } } }, + "%lld%%" : { + "comment" : "A text displaying the current volume percentage. The argument is a value between 0.0 (no volume) and 1.0 (full volume).", + "isCommentAutoGenerated" : true + }, "%lldpt" : { "comment" : "A caption below an app icon that shows its size in points. The argument is the size of the icon in points.", "isCommentAutoGenerated" : true }, + "$" : { + "comment" : "The dollar sign used in the top bar.", + "isCommentAutoGenerated" : true + }, + "$%@" : { + "comment" : "The value of the balance displayed in the top bar.", + "isCommentAutoGenerated" : true + }, "1. Use Xcode's preview to screenshot these icons" : { }, @@ -86,6 +102,28 @@ "comment" : "A title for the preview section of the icon export view.", "isCommentAutoGenerated" : true }, + "Balance" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Balance" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saldo" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solde" + } + } + } + }, "Card face down" : { "localizations" : { "en" : { @@ -108,6 +146,28 @@ } } }, + "Casual" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Casual" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Casual" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Décontracté" + } + } + } + }, "Chip selector" : { "localizations" : { "en" : { @@ -152,6 +212,28 @@ } } }, + "Dealing Speed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dealing Speed" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocidad de reparto" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vitesse de distribution" + } + } + } + }, "Diamonds" : { "localizations" : { "en" : { @@ -240,6 +322,28 @@ } } }, + "Exclusive VIP room" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exclusive VIP room" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sala VIP exclusiva" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salle VIP exclusive" + } + } + } + }, "Export Instructions" : { "comment" : "A section header describing how to export app icons.", "isCommentAutoGenerated" : true @@ -288,6 +392,50 @@ } } }, + "Game Over" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Game Over" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fin del juego" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fin de partie" + } + } + } + }, + "GAME OVER" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "GAME OVER" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "FIN DEL JUEGO" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "FIN DE PARTIE" + } + } + } + }, "Hearts" : { "localizations" : { "en" : { @@ -310,6 +458,50 @@ } } }, + "High roller table" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "High roller table" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mesa para grandes apostadores" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Table pour gros joueurs" + } + } + } + }, + "High Stakes" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "High Stakes" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apuestas altas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grosses mises" + } + } + } + }, "Jack" : { "localizations" : { "en" : { @@ -354,6 +546,28 @@ } } }, + "Low Stakes" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Low Stakes" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apuestas bajas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Petites mises" + } + } + } + }, "MAX" : { "localizations" : { "en" : { @@ -398,6 +612,28 @@ } } }, + "Medium Stakes" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium Stakes" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apuestas medias" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mises moyennes" + } + } + } + }, "Nine" : { "localizations" : { "en" : { @@ -420,6 +656,50 @@ } } }, + "Perfect for learning" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perfect for learning" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perfecto para aprender" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parfait pour apprendre" + } + } + } + }, + "Play Again" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play Again" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jugar de nuevo" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rejouer" + } + } + } + }, "Queen" : { "localizations" : { "en" : { @@ -442,6 +722,94 @@ } } }, + "Regular casino table" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regular casino table" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mesa de casino regular" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Table de casino standard" + } + } + } + }, + "Reset Game" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset Game" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reiniciar juego" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser" + } + } + } + }, + "Rounds Played" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rounds Played" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rondas jugadas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parties jouées" + } + } + } + }, + "Rules" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rules" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reglas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Règles" + } + } + } + }, "Selected" : { "localizations" : { "en" : { @@ -464,6 +832,28 @@ } } }, + "Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paramètres" + } + } + } + }, "Seven" : { "localizations" : { "en" : { @@ -534,6 +924,50 @@ } } }, + "Standard mini table" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard mini table" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mesa mini estándar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Table mini standard" + } + } + } + }, + "Statistics" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statistics" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estadísticas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statistiques" + } + } + } + }, "Ten" : { "localizations" : { "en" : { @@ -599,6 +1033,72 @@ } } } + }, + "VIP" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIP" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIP" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIP" + } + } + } + }, + "Volume" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volume" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volumen" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volume" + } + } + } + }, + "You've run out of chips!" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You've run out of chips!" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Te quedaste sin fichas!" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vous n'avez plus de jetons!" + } + } + } } }, "version" : "1.1" diff --git a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift index 76d7323..e992011 100644 --- a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift +++ b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift @@ -32,6 +32,7 @@ public enum CasinoDesign { public static let large: CGFloat = 16 public static let xLarge: CGFloat = 20 public static let xxLarge: CGFloat = 24 + public static let xxxLarge: CGFloat = 32 } // MARK: - Line Width @@ -49,6 +50,8 @@ public enum CasinoDesign { public static let radiusSmall: CGFloat = 4 public static let radiusMedium: CGFloat = 8 public static let radiusLarge: CGFloat = 12 + public static let radiusXLarge: CGFloat = 16 + public static let radiusXXLarge: CGFloat = 24 public static let offsetSmall: CGFloat = 1 public static let offsetMedium: CGFloat = 2 @@ -58,8 +61,10 @@ public enum CasinoDesign { // MARK: - Opacity public enum Opacity { - public static let subtle: CGFloat = 0.05 - public static let light: CGFloat = 0.2 + public static let verySubtle: CGFloat = 0.05 + public static let subtle: CGFloat = 0.1 + public static let hint: CGFloat = 0.2 + public static let light: CGFloat = 0.3 public static let quarter: CGFloat = 0.25 public static let medium: CGFloat = 0.5 public static let accent: CGFloat = 0.6 @@ -100,6 +105,19 @@ public enum CasinoDesign { /// Chip edge stripe dimensions. public static let chipStripeWidth: CGFloat = 4 public static let chipStripeInset: CGFloat = 2 + + /// iPad max widths for overlays and content. + public static let maxContentWidthPortrait: CGFloat = 500 + public static let maxContentWidthLandscape: CGFloat = 800 + public static let maxModalWidth: CGFloat = 450 + + /// Value badge size. + public static let valueBadge: CGFloat = 26 + + /// Icon sizes. + public static let iconSmall: CGFloat = 16 + public static let iconMedium: CGFloat = 20 + public static let iconLarge: CGFloat = 24 } // MARK: - Font Sizes (Base values for @ScaledMetric) @@ -162,5 +180,45 @@ public extension Color { /// Cancel button color. public static let cancelText = Color.white.opacity(CasinoDesign.Opacity.strong) } + + /// Button colors (gold gradient, destructive, etc.). + enum CasinoButton { + /// Light gold for button gradients. + public static let goldLight = Color(red: 1.0, green: 0.85, blue: 0.3) + /// Dark gold for button gradients. + public static let goldDark = Color(red: 0.9, green: 0.7, blue: 0.2) + /// Destructive button color (red). + public static let destructive = Color.red.opacity(0.8) + } + + /// Modal overlay colors. + enum CasinoModal { + /// Light background for modal cards. + public static let backgroundLight = Color(white: 0.15) + /// Dark background for modal cards. + public static let backgroundDark = Color(white: 0.08) + } + + /// Table colors. + enum CasinoTable { + /// Casino table green felt. + public static let felt = Color(red: 0.05, green: 0.25, blue: 0.15) + /// Darker felt for gradients. + public static let feltDark = Color(red: 0.02, green: 0.15, blue: 0.08) + /// Table edge border. + public static let border = Color(red: 0.3, green: 0.2, blue: 0.1) + /// Gold accent for table elements. + public static let gold = Color(red: 0.85, green: 0.65, blue: 0.2) + } + + /// Top bar colors. + enum CasinoTopBar { + /// Balance text color. + public static let balanceText = Color.yellow + /// Secondary info color. + public static let secondaryText = Color.white.opacity(CasinoDesign.Opacity.medium) + /// Icon button color. + public static let iconButton = Color.white.opacity(CasinoDesign.Opacity.strong) + } } diff --git a/CasinoKit/Sources/CasinoKit/Views/Badges/ValueBadge.swift b/CasinoKit/Sources/CasinoKit/Views/Badges/ValueBadge.swift new file mode 100644 index 0000000..873d743 --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/Badges/ValueBadge.swift @@ -0,0 +1,63 @@ +// +// ValueBadge.swift +// CasinoKit +// +// A circular badge showing a numeric value (hand value, score, etc.). +// + +import SwiftUI + +/// A circular badge showing a numeric value. +public struct ValueBadge: View { + /// The value to display. + public let value: Int + + /// The background color of the badge. + public let color: Color + + /// The size of the badge (default: uses CasinoDesign.Size.valueBadge). + public let size: CGFloat? + + // MARK: - Scaled Font Sizes (Dynamic Type) + + @ScaledMetric(relativeTo: .headline) private var valueFontSize: CGFloat = 15 + @ScaledMetric(relativeTo: .headline) private var badgeSize: CGFloat = CasinoDesign.Size.valueBadge + + /// Creates a value badge. + /// - Parameters: + /// - value: The numeric value to display. + /// - color: The background color. + /// - size: Optional custom size (overrides default). + public init(value: Int, color: Color, size: CGFloat? = nil) { + self.value = value + self.color = color + self.size = size + } + + private var displaySize: CGFloat { + size ?? badgeSize + } + + public var body: some View { + Text("\(value)") + .font(.system(size: valueFontSize, weight: .black, design: .rounded)) + .foregroundStyle(.white) + .frame(width: displaySize, height: displaySize) + .background( + Circle() + .fill(color) + ) + .accessibilityLabel("\(value)") + } +} + +#Preview { + HStack(spacing: 20) { + ValueBadge(value: 9, color: .blue) + ValueBadge(value: 8, color: .red) + ValueBadge(value: 0, color: .purple) + } + .padding() + .background(Color.black) +} + diff --git a/CasinoKit/Sources/CasinoKit/Views/Bars/TopBarView.swift b/CasinoKit/Sources/CasinoKit/Views/Bars/TopBarView.swift new file mode 100644 index 0000000..7ebb2e2 --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/Bars/TopBarView.swift @@ -0,0 +1,169 @@ +// +// TopBarView.swift +// CasinoKit +// +// A reusable top bar for casino games showing balance and toolbar buttons. +// + +import SwiftUI + +/// A top bar showing balance and customizable toolbar buttons. +public struct TopBarView: View { + /// The current balance to display. + public let balance: Int + + /// Optional secondary info (e.g., cards remaining). + public let secondaryInfo: String? + + /// Icon for secondary info. + public let secondaryIcon: String? + + /// Whether to show the reset button. + public let showReset: Bool + + /// Action when reset is tapped. + public let onReset: (() -> Void)? + + /// Action when settings is tapped. + public let onSettings: (() -> Void)? + + /// Action when help/rules is tapped. + public let onHelp: (() -> Void)? + + /// Action when stats is tapped. + public let onStats: (() -> Void)? + + // MARK: - Font Sizes (fixed for top bar constraints) + + private let balanceFontSize: CGFloat = 24 + private let dollarFontSize: CGFloat = 14 + private let secondaryFontSize: CGFloat = 14 + private let iconSize: CGFloat = 20 + + /// Creates a top bar. + /// - Parameters: + /// - balance: The current balance. + /// - secondaryInfo: Optional secondary info text. + /// - secondaryIcon: Optional SF Symbol for secondary info. + /// - showReset: Whether to show reset button. + /// - onReset: Reset button action. + /// - onSettings: Settings button action. + /// - onHelp: Help button action. + /// - onStats: Stats button action. + public init( + balance: Int, + secondaryInfo: String? = nil, + secondaryIcon: String? = nil, + showReset: Bool = true, + onReset: (() -> Void)? = nil, + onSettings: (() -> Void)? = nil, + onHelp: (() -> Void)? = nil, + onStats: (() -> Void)? = nil + ) { + self.balance = balance + self.secondaryInfo = secondaryInfo + self.secondaryIcon = secondaryIcon + self.showReset = showReset + self.onReset = onReset + self.onSettings = onSettings + self.onHelp = onHelp + self.onStats = onStats + } + + public var body: some View { + HStack { + // Balance display + HStack(spacing: CasinoDesign.Spacing.xxSmall) { + Text("$") + .font(.system(size: dollarFontSize, weight: .bold)) + .foregroundStyle(Color.CasinoTopBar.balanceText) + + Text(balance.formatted()) + .font(.system(size: balanceFontSize, weight: .bold, design: .rounded)) + .foregroundStyle(Color.CasinoTopBar.balanceText) + .contentTransition(.numericText()) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Balance", bundle: .module)) + .accessibilityValue("$\(balance.formatted())") + + Spacer() + + // Secondary info (centered) + if let info = secondaryInfo { + HStack(spacing: CasinoDesign.Spacing.xSmall) { + if let icon = secondaryIcon { + Image(systemName: icon) + } + Text(info) + } + .font(.system(size: secondaryFontSize)) + .foregroundStyle(Color.CasinoTopBar.secondaryText) + } + + Spacer() + + // Toolbar buttons + HStack(spacing: CasinoDesign.Spacing.medium) { + if let onStats = onStats { + ToolbarButton(icon: "chart.bar.fill", action: onStats) + .accessibilityLabel(String(localized: "Statistics", bundle: .module)) + } + + if let onHelp = onHelp { + ToolbarButton(icon: "info.circle", action: onHelp) + .accessibilityLabel(String(localized: "Rules", bundle: .module)) + } + + if let onSettings = onSettings { + ToolbarButton(icon: "gearshape.fill", action: onSettings) + .accessibilityLabel(String(localized: "Settings", bundle: .module)) + } + + if showReset, let onReset = onReset { + ToolbarButton(icon: "arrow.counterclockwise", action: onReset) + .accessibilityLabel(String(localized: "Reset Game", bundle: .module)) + } + } + } + .padding(.horizontal, CasinoDesign.Spacing.large) + .padding(.vertical, CasinoDesign.Spacing.small) + } +} + +/// A single toolbar button. +private struct ToolbarButton: View { + let icon: String + let action: () -> Void + + private let iconSize: CGFloat = 20 + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: iconSize)) + .foregroundStyle(Color.CasinoTopBar.iconButton) + } + } +} + +#Preview { + ZStack { + Color.CasinoTable.felt.ignoresSafeArea() + + VStack { + TopBarView( + balance: 10_500, + secondaryInfo: "411", + secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill", + onReset: {}, + onSettings: {}, + onHelp: {}, + onStats: {} + ) + + Spacer() + } + } +} + diff --git a/CasinoKit/Sources/CasinoKit/Views/Effects/ConfettiView.swift b/CasinoKit/Sources/CasinoKit/Views/Effects/ConfettiView.swift new file mode 100644 index 0000000..5b19e22 --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/Effects/ConfettiView.swift @@ -0,0 +1,93 @@ +// +// ConfettiView.swift +// CasinoKit +// +// A reusable confetti celebration effect for wins. +// + +import SwiftUI + +/// A single confetti particle that falls and rotates. +public struct ConfettiPiece: View { + let color: Color + let containerSize: CGSize + + @State private var position: CGPoint = .zero + @State private var rotation: Double = 0 + @State private var opacity: Double = 1 + + private let confettiWidth: CGFloat = 8 + private let confettiHeight: CGFloat = 12 + + public init(color: Color, containerSize: CGSize) { + self.color = color + self.containerSize = containerSize + } + + public var body: some View { + Rectangle() + .fill(color) + .frame(width: confettiWidth, height: confettiHeight) + .rotationEffect(.degrees(rotation)) + .position(position) + .opacity(opacity) + .onAppear { + let startX = Double.random(in: 0...containerSize.width) + position = CGPoint(x: startX, y: -20) + + withAnimation(.easeIn(duration: Double.random(in: 2...4))) { + position = CGPoint( + x: startX + Double.random(in: -100...100), + y: containerSize.height + 50 + ) + rotation = Double.random(in: 360...1080) + opacity = 0 + } + } + } +} + +/// A confetti celebration overlay for wins. +public struct ConfettiView: View { + /// The colors to use for confetti pieces. + public let colors: [Color] + + /// The number of confetti pieces to show. + public let count: Int + + /// Creates a confetti view with custom colors. + /// - Parameters: + /// - colors: The colors to randomly assign to confetti pieces. + /// - count: The number of confetti pieces (default: 50). + public init( + colors: [Color] = [.red, .blue, .green, .yellow, .orange, .purple, .pink], + count: Int = 50 + ) { + self.colors = colors + self.count = count + } + + public var body: some View { + GeometryReader { geometry in + ZStack { + ForEach(0.. Void + + @State private var showContent = false + + // MARK: - Scaled Font Sizes (Dynamic Type) + + @ScaledMetric(relativeTo: .largeTitle) private var iconSize: CGFloat = 64 + @ScaledMetric(relativeTo: .largeTitle) private var titleFontSize: CGFloat = 36 + @ScaledMetric(relativeTo: .body) private var messageFontSize: CGFloat = 18 + @ScaledMetric(relativeTo: .body) private var statsFontSize: CGFloat = 17 + @ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = 18 + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + /// Maximum width for the modal card on iPad + private var maxModalWidth: CGFloat { + horizontalSizeClass == .regular ? CasinoDesign.Size.maxModalWidth : .infinity + } + + /// Creates a game over view. + /// - Parameters: + /// - roundsPlayed: Number of rounds played. + /// - additionalStats: Extra stats to show (default: empty). + /// - onPlayAgain: Action when tapping "Play Again". + public init( + roundsPlayed: Int, + additionalStats: [(String, String)] = [], + onPlayAgain: @escaping () -> Void + ) { + self.roundsPlayed = roundsPlayed + self.additionalStats = additionalStats + self.onPlayAgain = onPlayAgain + } + + public var body: some View { + ZStack { + // Solid dark backdrop + Color.black + .ignoresSafeArea() + + // Modal card + VStack(spacing: CasinoDesign.Spacing.xxLarge) { + // Broke icon + Image(systemName: "creditcard.trianglebadge.exclamationmark") + .font(.system(size: iconSize)) + .foregroundStyle(.red) + .symbolEffect(.pulse, options: .repeating) + + // Title + Text(String(localized: "GAME OVER", bundle: .module)) + .font(.system(size: titleFontSize, weight: .black, design: .rounded)) + .foregroundStyle(.white) + + // Message + Text(String(localized: "You've run out of chips!", bundle: .module)) + .font(.system(size: messageFontSize, weight: .medium)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.strong)) + + // Stats card + VStack(spacing: CasinoDesign.Spacing.medium) { + StatRow( + label: String(localized: "Rounds Played", bundle: .module), + value: "\(roundsPlayed)", + fontSize: statsFontSize + ) + + ForEach(additionalStats.indices, id: \.self) { index in + StatRow( + label: additionalStats[index].0, + value: additionalStats[index].1, + fontSize: statsFontSize + ) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.large) + .fill(Color.white.opacity(CasinoDesign.Opacity.subtle)) + .overlay( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.large) + .strokeBorder(Color.white.opacity(CasinoDesign.Opacity.subtle), lineWidth: CasinoDesign.LineWidth.thin) + ) + ) + .padding(.horizontal, CasinoDesign.Spacing.xLarge) + + // Play Again button + Button { + onPlayAgain() + } label: { + HStack(spacing: CasinoDesign.Spacing.small) { + Image(systemName: "arrow.counterclockwise") + Text(String(localized: "Play Again", bundle: .module)) + } + .font(.system(size: buttonFontSize, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, CasinoDesign.Spacing.xxxLarge) + .padding(.vertical, CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall) + .background( + Capsule() + .fill( + LinearGradient( + colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark], + startPoint: .top, + endPoint: .bottom + ) + ) + ) + .shadow(color: .yellow.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusXLarge) + } + .padding(.top, CasinoDesign.Spacing.medium) + } + .padding(CasinoDesign.Spacing.xxxLarge) + .background( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.xxxLarge) + .fill( + LinearGradient( + colors: [Color.CasinoModal.backgroundLight, Color.CasinoModal.backgroundDark], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.xxxLarge) + .strokeBorder( + LinearGradient( + colors: [ + Color.red.opacity(CasinoDesign.Opacity.medium), + Color.red.opacity(CasinoDesign.Opacity.hint) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: CasinoDesign.LineWidth.medium + ) + ) + ) + .shadow(color: .red.opacity(CasinoDesign.Opacity.hint), radius: CasinoDesign.Shadow.radiusXXLarge) + .frame(maxWidth: maxModalWidth) + .padding(.horizontal, CasinoDesign.Spacing.xxLarge) + .scaleEffect(showContent ? 1.0 : 0.8) + .opacity(showContent ? 1.0 : 0) + } + .onAppear { + withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce)) { + showContent = true + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(String(localized: "Game Over", bundle: .module)) + .accessibilityAddTraits(.isModal) + } +} + +/// A single stat row for the game over view. +private struct StatRow: View { + let label: String + let value: String + let fontSize: CGFloat + + var body: some View { + HStack { + Text(label) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium)) + Spacer() + Text(value) + .bold() + .foregroundStyle(.white) + } + .font(.system(size: fontSize)) + } +} + +#Preview { + GameOverView( + roundsPlayed: 25, + additionalStats: [ + ("Biggest Win", "$5,000"), + ("Biggest Loss", "-$2,500") + ], + onPlayAgain: {} + ) +} + diff --git a/CasinoKit/Sources/CasinoKit/Views/Settings/SettingsComponents.swift b/CasinoKit/Sources/CasinoKit/Views/Settings/SettingsComponents.swift new file mode 100644 index 0000000..dd45cd4 --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/Settings/SettingsComponents.swift @@ -0,0 +1,223 @@ +// +// SettingsComponents.swift +// CasinoKit +// +// Reusable settings UI components for casino games. +// + +import SwiftUI + +// MARK: - Settings Toggle + +/// A toggle setting row with title and subtitle. +public struct SettingsToggle: View { + /// The main title text. + public let title: String + + /// The subtitle/description text. + public let subtitle: String + + /// Binding to the toggle state. + @Binding public var isOn: Bool + + /// Creates a settings toggle. + /// - Parameters: + /// - title: The main title. + /// - subtitle: The subtitle description. + /// - isOn: Binding to toggle state. + public init(title: String, subtitle: String, isOn: Binding) { + self.title = title + self.subtitle = subtitle + self._isOn = isOn + } + + public var body: some View { + Toggle(isOn: $isOn) { + VStack(alignment: .leading, spacing: CasinoDesign.Spacing.xxSmall) { + Text(title) + .font(.system(size: CasinoDesign.BaseFontSize.medium, weight: .medium)) + .foregroundStyle(.white) + + Text(subtitle) + .font(.system(size: CasinoDesign.BaseFontSize.body)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium)) + } + } + .tint(.yellow) + .padding(.vertical, CasinoDesign.Spacing.xSmall) + } +} + +// MARK: - Speed Picker + +/// A segmented picker for animation speed (Fast/Normal/Slow). +public struct SpeedPicker: View { + /// Binding to the speed value (0.5 = fast, 1.0 = normal, 2.0 = slow). + @Binding public var speed: Double + + private let options: [(String, Double)] = [ + ("Fast", 0.5), + ("Normal", 1.0), + ("Slow", 2.0) + ] + + /// Creates a speed picker. + /// - Parameter speed: Binding to the speed multiplier. + public init(speed: Binding) { + self._speed = speed + } + + public var body: some View { + VStack(alignment: .leading, spacing: CasinoDesign.Spacing.small) { + Text(String(localized: "Dealing Speed", bundle: .module)) + .font(.system(size: CasinoDesign.BaseFontSize.medium, weight: .medium)) + .foregroundStyle(.white) + + HStack(spacing: CasinoDesign.Spacing.small) { + ForEach(options, id: \.1) { option in + Button { + speed = option.1 + } label: { + Text(option.0) + .font(.system(size: CasinoDesign.BaseFontSize.body, weight: .medium)) + .foregroundStyle(speed == option.1 ? .black : .white.opacity(CasinoDesign.Opacity.strong)) + .padding(.vertical, CasinoDesign.Spacing.small) + .frame(maxWidth: .infinity) + .background( + Capsule() + .fill(speed == option.1 ? Color.yellow : Color.white.opacity(CasinoDesign.Opacity.subtle)) + ) + } + .buttonStyle(.plain) + } + } + } + .padding(.vertical, CasinoDesign.Spacing.xSmall) + } +} + +// MARK: - Volume Picker + +/// A volume slider with speaker icons. +public struct VolumePicker: View { + /// Binding to the volume level (0.0 to 1.0). + @Binding public var volume: Float + + /// Creates a volume picker. + /// - Parameter volume: Binding to volume (0.0-1.0). + public init(volume: Binding) { + self._volume = volume + } + + public var body: some View { + VStack(alignment: .leading, spacing: CasinoDesign.Spacing.small) { + HStack { + Text(String(localized: "Volume", bundle: .module)) + .font(.system(size: CasinoDesign.BaseFontSize.medium, weight: .medium)) + .foregroundStyle(.white) + + Spacer() + + Text("\(Int(volume * 100))%") + .font(.system(size: CasinoDesign.BaseFontSize.body, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium)) + } + + HStack(spacing: CasinoDesign.Spacing.medium) { + Image(systemName: "speaker.fill") + .font(.system(size: CasinoDesign.BaseFontSize.body)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium)) + + Slider(value: $volume, in: 0...1, step: 0.1) + .tint(.yellow) + + Image(systemName: "speaker.wave.3.fill") + .font(.system(size: CasinoDesign.BaseFontSize.body)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium)) + } + } + .padding(.vertical, CasinoDesign.Spacing.xSmall) + } +} + +// MARK: - Balance Picker + +/// A grid picker for selecting a starting balance. +public struct BalancePicker: View { + /// Binding to the selected balance. + @Binding public var balance: Int + + /// The available balance options. + public let options: [Int] + + /// Creates a balance picker. + /// - Parameters: + /// - balance: Binding to selected balance. + /// - options: Available balance options (default: standard values). + public init( + balance: Binding, + options: [Int] = [1_000, 5_000, 10_000, 25_000, 50_000, 100_000] + ) { + self._balance = balance + self.options = options + } + + public var body: some View { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: CasinoDesign.Spacing.small) { + ForEach(options, id: \.self) { amount in + Button { + balance = amount + } label: { + Text(formattedAmount(amount)) + .font(.system(size: CasinoDesign.BaseFontSize.body, weight: .bold)) + .foregroundStyle(balance == amount ? .black : .white) + .padding(.vertical, CasinoDesign.Spacing.medium) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.small) + .fill(balance == amount ? Color.yellow : Color.white.opacity(CasinoDesign.Opacity.subtle)) + ) + } + .buttonStyle(.plain) + } + } + } + + private func formattedAmount(_ amount: Int) -> String { + if amount >= 1000 { + return "$\(amount / 1000)K" + } + return "$\(amount)" + } +} + +#Preview { + ScrollView { + VStack(spacing: 20) { + SettingsToggle( + title: "Sound Effects", + subtitle: "Play sounds for game events", + isOn: .constant(true) + ) + + Divider().background(Color.white.opacity(0.1)) + + SpeedPicker(speed: .constant(1.0)) + + Divider().background(Color.white.opacity(0.1)) + + VolumePicker(volume: .constant(0.8)) + + Divider().background(Color.white.opacity(0.1)) + + BalancePicker(balance: .constant(10_000)) + } + .padding() + } + .background(Color.Sheet.background) +} + diff --git a/CasinoKit/Sources/CasinoKit/Views/Table/TableBackgroundView.swift b/CasinoKit/Sources/CasinoKit/Views/Table/TableBackgroundView.swift new file mode 100644 index 0000000..95caa0d --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/Table/TableBackgroundView.swift @@ -0,0 +1,97 @@ +// +// TableBackgroundView.swift +// CasinoKit +// +// A reusable casino table background with felt pattern. +// + +import SwiftUI + +/// A casino table felt background with radial gradient. +public struct TableBackgroundView: View { + /// The primary felt color (center of gradient). + public let feltColor: Color + + /// The darker edge color for the gradient. + public let edgeColor: Color + + /// Whether to show the decorative felt pattern. + public let showPattern: Bool + + /// Creates a table background. + /// - Parameters: + /// - feltColor: The main felt color (default: casino green). + /// - edgeColor: The darker edge color (default: dark green). + /// - showPattern: Whether to show the decorative pattern (default: true). + public init( + feltColor: Color = Color.CasinoTable.felt, + edgeColor: Color = Color.CasinoTable.feltDark, + showPattern: Bool = true + ) { + self.feltColor = feltColor + self.edgeColor = edgeColor + self.showPattern = showPattern + } + + public var body: some View { + ZStack { + // Base gradient + RadialGradient( + colors: [feltColor, edgeColor], + center: .center, + startRadius: 50, + endRadius: 600 + ) + .ignoresSafeArea() + + // Optional pattern overlay + if showPattern { + FeltPatternView() + .opacity(CasinoDesign.Opacity.verySubtle) + .ignoresSafeArea() + } + } + .accessibilityHidden(true) + } +} + +/// A subtle decorative pattern for the felt. +public struct FeltPatternView: View { + public init() {} + + public var body: some View { + GeometryReader { geometry in + Canvas { context, size in + let spacing = CasinoDesign.Size.patternSpacing + let diamondSize = CasinoDesign.Size.patternDiamondSize + + for x in stride(from: 0, to: size.width, by: spacing) { + for y in stride(from: 0, to: size.height, by: spacing) { + let offsetX = Int(y / spacing).isMultiple(of: 2) ? spacing / 2 : 0 + let rect = CGRect( + x: x + offsetX - diamondSize / 2, + y: y - diamondSize / 2, + width: diamondSize, + height: diamondSize + ) + + let path = Path { p in + p.move(to: CGPoint(x: rect.midX, y: rect.minY)) + p.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) + p.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) + p.addLine(to: CGPoint(x: rect.minX, y: rect.midY)) + p.closeSubpath() + } + + context.fill(path, with: .color(.white)) + } + } + } + } + } +} + +#Preview { + TableBackgroundView() +} +