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

This commit is contained in:
Matt Bruce 2025-12-17 13:58:02 -06:00
parent d58f50b6ac
commit e2785c3a48
11 changed files with 1499 additions and 59 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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..<count, id: \.self) { _ in
ConfettiPiece(
color: colors.randomElement() ?? .yellow,
containerSize: geometry.size
)
}
}
}
.ignoresSafeArea()
.allowsHitTesting(false)
.accessibilityHidden(true)
}
}
#Preview {
ZStack {
Color.black.ignoresSafeArea()
ConfettiView()
}
}

View File

@ -0,0 +1,201 @@
//
// GameOverView.swift
// CasinoKit
//
// A reusable game over overlay for when the player runs out of chips.
//
import SwiftUI
/// A full-screen game over overlay with play again button.
public struct GameOverView: View {
/// The number of rounds played in this session.
public let roundsPlayed: Int
/// Additional stats to display (label: value pairs).
public let additionalStats: [(String, String)]
/// Action when the player taps "Play Again".
public let onPlayAgain: () -> 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: {}
)
}

View File

@ -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<Bool>) {
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<Double>) {
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<Float>) {
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<Int>,
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)
}

View File

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