diff --git a/Baccarat/Agents.md b/Baccarat/Agents.md index adfa776..f4bf715 100644 --- a/Baccarat/Agents.md +++ b/Baccarat/Agents.md @@ -51,7 +51,8 @@ You are a **Senior iOS Engineer**, specializing in SwiftUI, SwiftData, and relat - When hiding scroll view indicators, use the `.scrollIndicators(.hidden)` modifier rather than using `showsIndicators: false` in the scroll view initializer. - Place view logic into view models or similar, so it can be tested. - Avoid `AnyView` unless it is absolutely required. -- Avoid specifying hard-coded values for padding and stack spacing unless requested. +- **Never use raw numeric literals** for padding, spacing, opacity, font sizes, dimensions, corner radii, shadows, or animation durations—always use Design constants (see "No magic numbers" section). +- **Never use inline `Color(red:green:blue:)` or hex colors**—define all colors in the `Color` extension in `DesignConstants.swift` with semantic names. - Avoid using UIKit colors in SwiftUI code. @@ -86,25 +87,97 @@ If SwiftData is configured to use CloudKit: - Never use `NSLocalizedString`; prefer the modern `String(localized:)` API. +## No magic numbers or hardcoded values + +**Never use raw numeric literals or hardcoded colors directly in views.** All values must be extracted to named constants, enums, or variables. This applies to: + +### Values that MUST be constants: +- **Spacing & Padding**: `.padding(Design.Spacing.medium)` not `.padding(12)` +- **Corner Radii**: `Design.CornerRadius.large` not `cornerRadius: 16` +- **Font Sizes**: `Design.BaseFontSize.body` not `size: 14` +- **Opacity Values**: `Design.Opacity.strong` not `.opacity(0.7)` +- **Colors**: `Color.Primary.accent` not `Color(red: 0.8, green: 0.6, blue: 0.2)` +- **Line Widths**: `Design.LineWidth.medium` not `lineWidth: 2` +- **Shadow Values**: `Design.Shadow.radiusLarge` not `radius: 10` +- **Animation Durations**: `Design.Animation.quick` not `duration: 0.3` +- **Component Sizes**: `Design.Size.chipBadge` not `frame(width: 32)` + +### What to do when you see a magic number: +1. Check if an appropriate constant already exists in `DesignConstants.swift` +2. If not, add a new constant with a semantic name +3. Use the constant in place of the raw value +4. If it's truly view-specific and used only once, extract to a `private let` at the top of the view struct + +### Examples of violations: +```swift +// ❌ BAD - Magic numbers everywhere +.padding(16) +.opacity(0.6) +.frame(width: 80, height: 52) +.shadow(radius: 10, y: 5) +Color(red: 0.25, green: 0.3, blue: 0.45) + +// ✅ GOOD - Named constants +.padding(Design.Spacing.large) +.opacity(Design.Opacity.accent) +.frame(width: Design.Size.bonusZoneWidth, height: Design.Size.topBetRowHeight) +.shadow(radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge) +Color.BettingZone.dragonBonusLight +``` + + ## Design constants instructions -- Avoid magic numbers for layout values (padding, spacing, corner radii, font sizes, etc.). - Create a centralized design constants file (e.g., `DesignConstants.swift`) using enums for namespacing: ```swift enum Design { enum Spacing { + static let xxSmall: CGFloat = 2 + static let xSmall: CGFloat = 4 static let small: CGFloat = 8 static let medium: CGFloat = 12 static let large: CGFloat = 16 + static let xLarge: CGFloat = 20 } enum CornerRadius { static let small: CGFloat = 8 static let medium: CGFloat = 12 + static let large: CGFloat = 16 } enum BaseFontSize { + static let small: CGFloat = 10 static let body: CGFloat = 14 + static let large: CGFloat = 18 static let title: CGFloat = 24 } + enum Opacity { + static let subtle: Double = 0.1 + static let hint: Double = 0.2 + static let light: Double = 0.3 + static let medium: Double = 0.5 + static let accent: Double = 0.6 + static let strong: Double = 0.7 + static let heavy: Double = 0.8 + static let almostFull: Double = 0.9 + } + enum LineWidth { + static let thin: CGFloat = 1 + static let medium: CGFloat = 2 + static let thick: CGFloat = 3 + } + enum Shadow { + static let radiusSmall: CGFloat = 2 + static let radiusMedium: CGFloat = 6 + static let radiusLarge: CGFloat = 10 + static let offsetSmall: CGFloat = 1 + static let offsetMedium: CGFloat = 3 + } + enum Animation { + static let quick: Double = 0.3 + static let springDuration: Double = 0.4 + static let staggerDelay1: Double = 0.1 + static let staggerDelay2: Double = 0.25 + } } ``` - For colors used across the app, extend `Color` with semantic color definitions: @@ -114,18 +187,26 @@ If SwiftData is configured to use CloudKit: static let background = Color(red: 0.1, green: 0.2, blue: 0.3) static let accent = Color(red: 0.8, green: 0.6, blue: 0.2) } + enum Button { + static let goldLight = Color(red: 1.0, green: 0.85, blue: 0.3) + static let goldDark = Color(red: 0.9, green: 0.7, blue: 0.2) + } } ``` -- Within each view, extract view-specific magic numbers to private constants at the top of the struct: +- Within each view, extract view-specific magic numbers to private constants at the top of the struct with a comment explaining why they're local: ```swift struct MyView: View { + // Layout: fixed card dimensions for consistent appearance private let cardWidth: CGFloat = 45 + // Typography: constrained space requires fixed size private let headerFontSize: CGFloat = 18 // ... } ``` - Reference design constants in views: `Design.Spacing.medium`, `Design.CornerRadius.large`, `Color.Primary.accent`. - Keep design constants organized by category: Spacing, CornerRadius, BaseFontSize, IconSize, Size, Animation, Opacity, LineWidth, Shadow. +- When adding new features, check existing constants first before creating new ones. +- Name constants semantically (what they represent) not literally (their value): `accent` not `pointSix`, `large` not `sixteen`. ## Dynamic Type instructions diff --git a/Baccarat/Engine/GameState.swift b/Baccarat/Engine/GameState.swift index 4eedde1..953a1a1 100644 --- a/Baccarat/Engine/GameState.swift +++ b/Baccarat/Engine/GameState.swift @@ -19,6 +19,31 @@ enum GamePhase: Equatable { case roundComplete } +/// Result of an individual bet after a round. +struct BetResult: Identifiable { + let id = UUID() + let type: BetType + let amount: Int + let payout: Int // Net winnings (positive) or loss (negative) + + var isWin: Bool { payout > 0 } + var isLoss: Bool { payout < 0 } + var isPush: Bool { payout == 0 } + + /// Display name for the bet type + var displayName: String { + switch type { + case .player: return "Player" + case .banker: return "Banker" + case .tie: return "Tie" + case .playerPair: return "P Pair" + case .bankerPair: return "B Pair" + case .dragonBonusPlayer: return "Dragon P" + case .dragonBonusBanker: return "Dragon B" + } + } +} + /// Main observable game state class managing all game logic and UI state. @Observable @MainActor @@ -38,10 +63,10 @@ final class GameState { var lastResult: GameResult? var lastWinnings: Int = 0 - // MARK: - Side Bet Results + // MARK: - Bet Results var playerHadPair: Bool = false var bankerHadPair: Bool = false - var dragonBonusPayouts: [BetType: Int] = [:] + var betResults: [BetResult] = [] // MARK: - Card Display State (for animations) var visiblePlayerCards: [Card] = [] @@ -224,7 +249,7 @@ final class GameState { isAnimating = true engine.prepareNewRound() - // Clear visible cards and side bet results + // Clear visible cards and bet results visiblePlayerCards = [] visibleBankerCards = [] playerCardsFaceUp = [] @@ -233,7 +258,7 @@ final class GameState { showResultBanner = false playerHadPair = false bankerHadPair = false - dragonBonusPayouts = [:] + betResults = [] // Deal initial cards currentPhase = .dealingInitial @@ -331,16 +356,16 @@ final class GameState { playerHadPair = engine.playerHasPair bankerHadPair = engine.bankerHasPair - // Calculate and apply payouts + // Calculate and apply payouts, track individual results var totalWinnings = 0 + var results: [BetResult] = [] + for bet in currentBets { let payout = engine.calculatePayout(bet: bet, result: result) totalWinnings += payout - // Track dragon bonus payouts for display - if bet.type == .dragonBonusPlayer || bet.type == .dragonBonusBanker { - dragonBonusPayouts[bet.type] = payout - } + // Track individual bet result + results.append(BetResult(type: bet.type, amount: bet.amount, payout: payout)) // Return original bet if not a loss if payout >= 0 { @@ -353,6 +378,7 @@ final class GameState { } } + betResults = results lastWinnings = totalWinnings // Record result in history @@ -385,7 +411,7 @@ final class GameState { lastWinnings = 0 playerHadPair = false bankerHadPair = false - dragonBonusPayouts = [:] + betResults = [] currentPhase = .betting } @@ -415,7 +441,7 @@ final class GameState { showResultBanner = false playerHadPair = false bankerHadPair = false - dragonBonusPayouts = [:] + betResults = [] } /// Applies new settings (call after settings change). diff --git a/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Resources/Localizable.xcstrings index 09aa46b..eb19ca3 100644 --- a/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Resources/Localizable.xcstrings @@ -40,6 +40,10 @@ "comment" : "A bullet point used to list items in a rule section.", "isCommentAutoGenerated" : true }, + "+%lld" : { + "comment" : "A text element displaying the total winnings in the round, prefixed by a plus sign. The argument is the total winnings amount.", + "isCommentAutoGenerated" : true + }, "$" : { "comment" : "The currency symbol \"$\".", "localizations" : { @@ -114,10 +118,14 @@ } } }, - "8 TO 1" : { + "8 : 1" : { "comment" : "The payout ratio for a tie bet.", "isCommentAutoGenerated" : true }, + "11 : 1" : { + "comment" : "The payout ratio for a pair bet.", + "isCommentAutoGenerated" : true + }, "11:1" : { "comment" : "The payout ratio for a pair bonus bet.", "isCommentAutoGenerated" : true @@ -624,17 +632,8 @@ "comment" : "A heading for important information related to a section of a view.", "isCommentAutoGenerated" : true }, - "lostAmountFormat" : { - "comment" : "Format string used to describe the amount lost in a round.", - "isCommentAutoGenerated" : true - }, - "M" : { - "comment" : "The letter \"M\" displayed on a mini chip indicator to represent the maximum bet.", - "isCommentAutoGenerated" : true - }, "MAX" : { "comment" : "A label displayed as a badge on top-right of a chip to indicate it's the maximum bet.", - "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -1223,6 +1222,10 @@ "comment" : "A section header for tips related to pair bonuses.", "isCommentAutoGenerated" : true }, + "TOTAL" : { + "comment" : "A label displayed next to the total winnings in the result banner.", + "isCommentAutoGenerated" : true + }, "WIN" : { "comment" : "The text that appears as a badge when a player wins a hand in baccarat.", "localizations" : { @@ -1262,10 +1265,6 @@ "comment" : "A description of the player's hand, including its value and whether they won.", "isCommentAutoGenerated" : true }, - "wonAmountFormat" : { - "comment" : "Format string used to describe the amount won in a localized manner.", - "isCommentAutoGenerated" : true - }, "You've run out of chips!" : { "comment" : "A message displayed when a player runs out of money in the game over screen.", "localizations" : { diff --git a/Baccarat/Theme/DesignConstants.swift b/Baccarat/Theme/DesignConstants.swift index d6f9dc8..1462f06 100644 --- a/Baccarat/Theme/DesignConstants.swift +++ b/Baccarat/Theme/DesignConstants.swift @@ -70,6 +70,8 @@ enum Design { static let chipSmall: CGFloat = 36 static let chipMedium: CGFloat = 50 static let chipSelector: CGFloat = 50 + static let chipBadge: CGFloat = 32 + static let chipBadgeInner: CGFloat = 28 static let cardWidthSmall: CGFloat = 45 static let cardWidthMedium: CGFloat = 55 static let cardWidthLarge: CGFloat = 65 @@ -77,6 +79,10 @@ enum Design { static let checkmark: CGFloat = 22 static let tableAspectRatio: CGFloat = 1.6 static let roadMapCell: CGFloat = 16 + static let diamondIcon: CGFloat = 24 + static let topBetRowHeight: CGFloat = 52 + static let mainBetRowHeight: CGFloat = 65 + static let bonusZoneWidth: CGFloat = 80 } // MARK: - Animation @@ -89,8 +95,9 @@ enum Design { static let fadeInDuration: Double = 0.3 static let cardFlipDuration: Double = 0.5 static let selectionDuration: Double = 0.2 - static let staggerDelay1: Double = 0.2 - static let staggerDelay2: Double = 0.4 + static let staggerDelay1: Double = 0.1 + static let staggerDelay2: Double = 0.25 + static let staggerDelay3: Double = 0.4 } // MARK: - Opacity @@ -98,12 +105,15 @@ enum Design { enum Opacity { static let verySubtle: Double = 0.05 static let subtle: Double = 0.1 + static let selection: Double = 0.15 static let hint: Double = 0.2 + static let quarter: Double = 0.25 static let light: Double = 0.3 static let overlay: Double = 0.4 static let medium: Double = 0.5 static let secondary: Double = 0.5 static let disabled: Double = 0.5 + static let accent: Double = 0.6 static let strong: Double = 0.7 static let heavy: Double = 0.8 static let nearOpaque: Double = 0.85 @@ -140,11 +150,15 @@ enum Design { // MARK: - Shadow enum Shadow { - static let radiusSmall: CGFloat = 3 + static let radiusSmall: CGFloat = 2 static let radiusMedium: CGFloat = 6 static let radiusLarge: CGFloat = 10 static let radiusXLarge: CGFloat = 12 static let radiusXXLarge: CGFloat = 30 + + static let offsetSmall: CGFloat = 1 + static let offsetMedium: CGFloat = 3 + static let offsetLarge: CGFloat = 5 } } @@ -190,6 +204,10 @@ extension Color { // Tie (Green) static let tie = Color(red: 0.1, green: 0.45, blue: 0.25) static let tieMax = Color(red: 0.08, green: 0.32, blue: 0.18) + + // Dragon Bonus (Purple/Blue gradient) + static let dragonBonusLight = Color(red: 0.25, green: 0.3, blue: 0.45) + static let dragonBonusDark = Color(red: 0.15, green: 0.2, blue: 0.35) } // MARK: - Button Colors diff --git a/Baccarat/Views/GameTableView.swift b/Baccarat/Views/GameTableView.swift index 10305df..d315166 100644 --- a/Baccarat/Views/GameTableView.swift +++ b/Baccarat/Views/GameTableView.swift @@ -32,29 +32,6 @@ struct GameTableView: View { state.lastResult == .tie } - /// Builds descriptions for side bet wins to display in the result banner. - private func buildSideBetDescriptions(state: GameState) -> [String] { - var descriptions: [String] = [] - - // Check pair bets - if state.playerHadPair && state.bet(for: .playerPair) != nil { - descriptions.append("Player Pair Win!") - } - if state.bankerHadPair && state.bet(for: .bankerPair) != nil { - descriptions.append("Banker Pair Win!") - } - - // Check dragon bonus payouts - if let payout = state.dragonBonusPayouts[.dragonBonusPlayer], payout > 0 { - descriptions.append("Dragon Player +\(payout)") - } - if let payout = state.dragonBonusPayouts[.dragonBonusBanker], payout > 0 { - descriptions.append("Dragon Banker +\(payout)") - } - - return descriptions - } - var body: some View { ZStack { // Table background @@ -134,10 +111,10 @@ struct GameTableView: View { if state.showResultBanner, let result = state.lastResult { ResultBannerView( result: result, - winnings: state.lastWinnings, + totalWinnings: state.lastWinnings, + betResults: state.betResults, playerHadPair: state.playerHadPair, - bankerHadPair: state.bankerHadPair, - sideBetWinnings: buildSideBetDescriptions(state: state) + bankerHadPair: state.bankerHadPair ) .transition(.opacity) @@ -238,7 +215,7 @@ struct GameOverView: View { .padding() .background( RoundedRectangle(cornerRadius: statsCornerRadius) - .fill(Color.white.opacity(0.08)) + .fill(Color.white.opacity(Design.Opacity.subtle)) .overlay( RoundedRectangle(cornerRadius: statsCornerRadius) .strokeBorder(Color.white.opacity(Design.Opacity.subtle), lineWidth: Design.LineWidth.thin) @@ -288,7 +265,7 @@ struct GameOverView: View { LinearGradient( colors: [ Color.red.opacity(Design.Opacity.medium), - Color.red.opacity(0.2) + Color.red.opacity(Design.Opacity.hint) ], startPoint: .topLeading, endPoint: .bottomTrailing @@ -297,7 +274,7 @@ struct GameOverView: View { ) ) ) - .shadow(color: .red.opacity(0.2), radius: Design.Shadow.radiusXXLarge) + .shadow(color: .red.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXXLarge) .padding(.horizontal, Design.Spacing.xxLarge) .scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink) .opacity(showContent ? 1.0 : 0) @@ -421,7 +398,7 @@ struct CardsDisplayArea: View { .padding(.horizontal, Design.Spacing.xLarge) .background( RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge) - .fill(Color.black.opacity(0.25)) + .fill(Color.black.opacity(Design.Opacity.quarter)) .accessibilityHidden(true) ) .padding(.horizontal) @@ -588,7 +565,7 @@ struct TopBarView: View { HStack(spacing: Design.Spacing.xSmall) { Text("$") .font(.system(size: currencyFontSize, weight: .bold)) - .foregroundStyle(.yellow.opacity(0.8)) + .foregroundStyle(.yellow.opacity(Design.Opacity.heavy)) Text(balance, format: .number) .font(.system(size: balanceFontSize, weight: .black, design: .rounded)) @@ -630,7 +607,7 @@ struct TopBarView: View { Button("Help", systemImage: "info.circle.fill", action: onHelp) .labelStyle(.iconOnly) .font(.system(size: buttonFontSize)) - .foregroundStyle(.white.opacity(0.6)) + .foregroundStyle(.white.opacity(Design.Opacity.accent)) .padding(Design.Spacing.small) .background( Circle() @@ -641,7 +618,7 @@ struct TopBarView: View { Button("Settings", systemImage: "gearshape.fill", action: onSettings) .labelStyle(.iconOnly) .font(.system(size: buttonFontSize)) - .foregroundStyle(.white.opacity(0.6)) + .foregroundStyle(.white.opacity(Design.Opacity.accent)) .padding(Design.Spacing.small) .background( Circle() @@ -652,7 +629,7 @@ struct TopBarView: View { Button("Reset", systemImage: "arrow.counterclockwise", action: onReset) .labelStyle(.iconOnly) .font(.system(size: buttonFontSize)) - .foregroundStyle(.white.opacity(0.6)) + .foregroundStyle(.white.opacity(Design.Opacity.accent)) .padding(Design.Spacing.small) .background( Circle() diff --git a/Baccarat/Views/MiniBaccaratTableView.swift b/Baccarat/Views/MiniBaccaratTableView.swift index ca5ad6d..62ad753 100644 --- a/Baccarat/Views/MiniBaccaratTableView.swift +++ b/Baccarat/Views/MiniBaccaratTableView.swift @@ -81,8 +81,8 @@ struct MiniBaccaratTableView: View { // Divider Rectangle() - .fill(Color.Border.gold.opacity(0.5)) - .frame(height: 1) + .fill(Color.Border.gold.opacity(Design.Opacity.medium)) + .frame(height: Design.LineWidth.thin) // Middle row: BANKER | BONUS MainBetRow( @@ -102,8 +102,8 @@ struct MiniBaccaratTableView: View { // Divider Rectangle() - .fill(Color.Border.gold.opacity(0.5)) - .frame(height: 1) + .fill(Color.Border.gold.opacity(Design.Opacity.medium)) + .frame(height: Design.LineWidth.thin) // Bottom row: PLAYER | BONUS MainBetRow( @@ -133,7 +133,7 @@ struct MiniBaccaratTableView: View { lineWidth: Design.LineWidth.medium ) ) - .shadow(color: .black.opacity(0.3), radius: 10, y: 5) + .shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge) } } } @@ -154,8 +154,6 @@ private struct TopBettingRow: View { let onTie: () -> Void let onPlayerPair: () -> Void - private let rowHeight: CGFloat = 52 - var body: some View { HStack(spacing: 0) { // B PAIR @@ -164,14 +162,14 @@ private struct TopBettingRow: View { betAmount: bankerPairAmount, isEnabled: canBetBankerPair, isAtMax: isBankerPairAtMax, - color: Color.BettingZone.bankerDark.opacity(0.6), + color: Color.BettingZone.bankerDark.opacity(Design.Opacity.accent), action: onBankerPair ) // Vertical divider Rectangle() - .fill(Color.Border.gold.opacity(0.5)) - .frame(width: 1) + .fill(Color.Border.gold.opacity(Design.Opacity.medium)) + .frame(width: Design.LineWidth.thin) // TIE TieBetZone( @@ -183,8 +181,8 @@ private struct TopBettingRow: View { // Vertical divider Rectangle() - .fill(Color.Border.gold.opacity(0.5)) - .frame(width: 1) + .fill(Color.Border.gold.opacity(Design.Opacity.medium)) + .frame(width: Design.LineWidth.thin) // P PAIR PairBetZone( @@ -192,11 +190,11 @@ private struct TopBettingRow: View { betAmount: playerPairAmount, isEnabled: canBetPlayerPair, isAtMax: isPlayerPairAtMax, - color: Color.BettingZone.playerDark.opacity(0.6), + color: Color.BettingZone.playerDark.opacity(Design.Opacity.accent), action: onPlayerPair ) } - .frame(height: rowHeight) + .frame(height: Design.Size.topBetRowHeight) } } @@ -210,9 +208,6 @@ private struct PairBetZone: View { let color: Color let action: () -> Void - private let titleFontSize: CGFloat = 12 - private let payoutFontSize: CGFloat = 10 - var body: some View { Button { if isEnabled { action() } @@ -223,25 +218,27 @@ private struct PairBetZone: View { .fill(color) // Content - VStack(spacing: 2) { + VStack(spacing: Design.Spacing.xxSmall) { Text(title) - .font(.system(size: titleFontSize, weight: .heavy, design: .rounded)) + .font(.system(size: Design.BaseFontSize.body, weight: .heavy, design: .rounded)) .foregroundStyle(.yellow) Text("11 : 1") - .font(.system(size: payoutFontSize, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(0.7)) + .font(.system(size: Design.BaseFontSize.small, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) } - // Chip indicator + // Chip indicator - center right with padding if betAmount > 0 { - ChipBadge(amount: betAmount, isMax: isAtMax) - .offset(y: 16) + HStack { + Spacer() + ChipBadge(amount: betAmount, isMax: isAtMax) + .padding(.trailing, Design.Spacing.xSmall + Design.Spacing.xxSmall) + } } } } .buttonStyle(.plain) - .opacity(isEnabled ? 1.0 : 0.5) .accessibilityElement(children: .ignore) .accessibilityLabel("\(title) bet, pays 11 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : "")) .accessibilityAddTraits(.isButton) @@ -256,9 +253,6 @@ private struct TieBetZone: View { let isAtMax: Bool let action: () -> Void - private let titleFontSize: CGFloat = 14 - private let payoutFontSize: CGFloat = 10 - var body: some View { Button { if isEnabled { action() } @@ -269,26 +263,28 @@ private struct TieBetZone: View { .fill(isAtMax ? Color.BettingZone.tieMax : Color.BettingZone.tie) // Content - VStack(spacing: 2) { + VStack(spacing: Design.Spacing.xxSmall) { Text("TIE") - .font(.system(size: titleFontSize, weight: .black, design: .rounded)) + .font(.system(size: Design.BaseFontSize.medium, weight: .black, design: .rounded)) .tracking(1) Text("8 : 1") - .font(.system(size: payoutFontSize, weight: .medium, design: .rounded)) - .opacity(0.7) + .font(.system(size: Design.BaseFontSize.small, weight: .medium, design: .rounded)) + .opacity(Design.Opacity.strong) } .foregroundStyle(.white) - // Chip indicator + // Chip indicator - center right with padding if betAmount > 0 { - ChipBadge(amount: betAmount, isMax: isAtMax) - .offset(y: 16) + HStack { + Spacer() + ChipBadge(amount: betAmount, isMax: isAtMax) + .padding(.trailing, Design.Spacing.xSmall + Design.Spacing.xxSmall) + } } } } .buttonStyle(.plain) - .opacity(isEnabled ? 1.0 : 0.5) .accessibilityElement(children: .ignore) .accessibilityLabel("Tie bet, pays 8 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : "")) .accessibilityAddTraits(.isButton) @@ -311,9 +307,6 @@ private struct MainBetRow: View { let onMain: () -> Void let onBonus: () -> Void - private let rowHeight: CGFloat = 65 - private let bonusWidth: CGFloat = 80 - var body: some View { HStack(spacing: 0) { // Main bet zone (BANKER or PLAYER) @@ -330,8 +323,8 @@ private struct MainBetRow: View { // Vertical divider Rectangle() - .fill(Color.Border.gold.opacity(0.5)) - .frame(width: 1) + .fill(Color.Border.gold.opacity(Design.Opacity.medium)) + .frame(width: Design.LineWidth.thin) // Dragon Bonus zone DragonBonusZone( @@ -340,9 +333,9 @@ private struct MainBetRow: View { isAtMax: isBonusAtMax, action: onBonus ) - .frame(width: bonusWidth) + .frame(width: Design.Size.bonusZoneWidth) } - .frame(height: rowHeight) + .frame(height: Design.Size.mainBetRowHeight) } } @@ -358,9 +351,6 @@ private struct MainBetZone: View { let color: Color let action: () -> Void - private let titleFontSize: CGFloat = 20 - private let payoutFontSize: CGFloat = 11 - var body: some View { Button { if isEnabled { action() } @@ -373,39 +363,35 @@ private struct MainBetZone: View { // Selection highlight if isSelected { Rectangle() - .fill(Color.yellow.opacity(0.15)) + .fill(Color.yellow.opacity(Design.Opacity.selection)) Rectangle() - .strokeBorder(Color.yellow, lineWidth: 3) + .strokeBorder(Color.yellow, lineWidth: Design.LineWidth.thick) } - // Content - HStack { - Spacer() + // Content - always centered + VStack(spacing: Design.Spacing.xxSmall + 1) { + Text(title) + .font(.system(size: Design.BaseFontSize.xxLarge, weight: .black, design: .rounded)) + .tracking(2) - VStack(spacing: 3) { - Text(title) - .font(.system(size: titleFontSize, weight: .black, design: .rounded)) - .tracking(2) - - Text(payoutText) - .font(.system(size: payoutFontSize, weight: .semibold, design: .rounded)) - .opacity(0.7) - } - .foregroundStyle(.white) - - Spacer() - - // Chip indicator - if betAmount > 0 { + Text(payoutText) + .font(.system(size: Design.BaseFontSize.callout - 2, weight: .semibold, design: .rounded)) + .opacity(Design.Opacity.strong) + } + .foregroundStyle(.white) + + // Chip indicator - overlaid on right, doesn't affect centering + if betAmount > 0 { + HStack { + Spacer() ChipOnTableView(amount: betAmount, showMax: isAtMax) - .padding(.trailing, Design.Spacing.medium) + .padding(.trailing, Design.Spacing.small) } } } } .buttonStyle(.plain) - .opacity(isEnabled ? 1.0 : 0.5) .accessibilityElement(children: .ignore) .accessibilityLabel("\(title) bet, pays \(payoutText)" + (isSelected ? ", selected" : "") + (betAmount > 0 ? ", current bet $\(betAmount)" : "")) .accessibilityAddTraits(.isButton) @@ -420,9 +406,6 @@ private struct DragonBonusZone: View { let isAtMax: Bool let action: () -> Void - private let titleFontSize: CGFloat = 9 - private let diamondSize: CGFloat = 24 - var body: some View { Button { if isEnabled { action() } @@ -433,8 +416,8 @@ private struct DragonBonusZone: View { .fill( LinearGradient( colors: [ - Color(red: 0.25, green: 0.3, blue: 0.45), - Color(red: 0.15, green: 0.2, blue: 0.35) + Color.BettingZone.dragonBonusLight, + Color.BettingZone.dragonBonusDark ], startPoint: .top, endPoint: .bottom @@ -442,36 +425,38 @@ private struct DragonBonusZone: View { ) // Content - VStack(spacing: 5) { + VStack(spacing: Design.Spacing.xSmall) { // Diamond shape DiamondShape() .fill( LinearGradient( - colors: [Color.purple.opacity(0.8), Color.purple.opacity(0.5)], + colors: [Color.purple.opacity(Design.Opacity.heavy), Color.purple.opacity(Design.Opacity.medium)], startPoint: .top, endPoint: .bottom ) ) - .frame(width: diamondSize, height: diamondSize) + .frame(width: Design.Size.diamondIcon, height: Design.Size.diamondIcon) .overlay( DiamondShape() - .strokeBorder(Color.white.opacity(0.4), lineWidth: 1) + .strokeBorder(Color.white.opacity(Design.Opacity.overlay), lineWidth: Design.LineWidth.thin) ) Text("BONUS") - .font(.system(size: titleFontSize, weight: .bold, design: .rounded)) - .foregroundStyle(.white.opacity(0.9)) + .font(.system(size: Design.BaseFontSize.xSmall, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(Design.Opacity.almostFull)) } - // Chip indicator + // Chip indicator - center right with padding (same as top row) if betAmount > 0 { - ChipBadge(amount: betAmount, isMax: isAtMax) - .offset(y: 22) + HStack { + Spacer() + ChipBadge(amount: betAmount, isMax: isAtMax) + .padding(.trailing, Design.Spacing.xSmall + Design.Spacing.xxSmall) + } } } } .buttonStyle(.plain) - .opacity(isEnabled ? 1.0 : 0.5) .accessibilityElement(children: .ignore) .accessibilityLabel("Dragon Bonus, pays up to 30 to 1" + (betAmount > 0 ? ", current bet $\(betAmount)" : "")) .accessibilityAddTraits(.isButton) @@ -503,7 +488,7 @@ private struct DiamondShape: InsettableShape { } } -// MARK: - Chip Badge (small indicator) +// MARK: - Chip Badge (indicator for side bets) private struct ChipBadge: View { let amount: Int @@ -511,24 +496,28 @@ private struct ChipBadge: View { var body: some View { ZStack { + // Outer ring Circle() .fill(isMax ? Color.gray : Color.yellow) - .frame(width: 20, height: 20) + .frame(width: Design.Size.chipBadge, height: Design.Size.chipBadge) + // Inner decoration Circle() - .strokeBorder(Color.white.opacity(0.8), lineWidth: 1) - .frame(width: 20, height: 20) + .strokeBorder(Color.white.opacity(Design.Opacity.almostFull), lineWidth: Design.LineWidth.standard) + .frame(width: Design.Size.chipBadgeInner, height: Design.Size.chipBadgeInner) + // Text if isMax { - Text("M") - .font(.system(size: 9, weight: .bold)) + Text("MAX") + .font(.system(size: Design.BaseFontSize.xSmall, weight: .black)) .foregroundStyle(.white) } else { Text(formatCompact(amount)) - .font(.system(size: 7, weight: .bold)) + .font(.system(size: Design.BaseFontSize.body, weight: .bold, design: .rounded)) .foregroundStyle(.black) } } + .shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusSmall, y: Design.Shadow.offsetSmall) } private func formatCompact(_ value: Int) -> String { diff --git a/Baccarat/Views/ResultBannerView.swift b/Baccarat/Views/ResultBannerView.swift index af9000f..2cd8d51 100644 --- a/Baccarat/Views/ResultBannerView.swift +++ b/Baccarat/Views/ResultBannerView.swift @@ -2,30 +2,44 @@ // ResultBannerView.swift // Baccarat // -// Animated result banner showing the winner and winnings. +// Animated result banner showing the winner and itemized bet results. // import SwiftUI import CasinoKit -/// An animated banner showing the round result. +/// An animated banner showing the round result with bet breakdown. struct ResultBannerView: View { let result: GameResult - let winnings: Int + let totalWinnings: Int + let betResults: [BetResult] var playerHadPair: Bool = false var bankerHadPair: Bool = false - var sideBetWinnings: [String] = [] // List of side bet win descriptions @State private var showBanner = false @State private var showText = false - @State private var showWinnings = false - @State private var showSideBets = false + @State private var showBreakdown = false + @State private var showTotal = false // MARK: - Scaled Font Sizes (Dynamic Type) @ScaledMetric(relativeTo: .largeTitle) private var resultFontSize: CGFloat = Design.BaseFontSize.largeTitle - @ScaledMetric(relativeTo: .title2) private var winningsFontSize: CGFloat = 28 - @ScaledMetric(relativeTo: .body) private var sideBetFontSize: CGFloat = 14 + @ScaledMetric(relativeTo: .title3) private var totalFontSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall + @ScaledMetric(relativeTo: .body) private var itemFontSize: CGFloat = Design.BaseFontSize.medium + + // MARK: - Computed Properties + + private var winningBets: [BetResult] { + betResults.filter { $0.isWin } + } + + private var losingBets: [BetResult] { + betResults.filter { $0.isLoss } + } + + private var pushBets: [BetResult] { + betResults.filter { $0.isPush } + } var body: some View { ZStack { @@ -60,47 +74,47 @@ struct ResultBannerView: View { PairBadge(label: "B PAIR", color: .red) } } - .scaleEffect(showSideBets ? Design.Scale.normal : Design.Scale.shrunk) - .opacity(showSideBets ? Design.Scale.normal : 0) + .scaleEffect(showBreakdown ? Design.Scale.normal : Design.Scale.shrunk) + .opacity(showBreakdown ? Design.Scale.normal : 0) } - // Winnings display - if winnings != 0 { + // Bet breakdown + if !betResults.isEmpty { + BetBreakdownView( + winningBets: winningBets, + losingBets: losingBets, + pushBets: pushBets, + fontSize: itemFontSize + ) + .scaleEffect(showBreakdown ? Design.Scale.normal : Design.Scale.shrunk) + .opacity(showBreakdown ? Design.Scale.normal : 0) + } + + // Total + if totalWinnings != 0 { HStack(spacing: Design.Spacing.small) { - if winnings > 0 { - Image(systemName: "plus.circle.fill") - .foregroundStyle(.green) - Text("\(winnings)") + Text("TOTAL") + .font(.system(size: Design.BaseFontSize.body, weight: .bold)) + .foregroundStyle(.white.opacity(Design.Opacity.accent)) + + if totalWinnings > 0 { + Text("+\(totalWinnings)") + .font(.system(size: totalFontSize, weight: .black, design: .rounded)) .foregroundStyle(.green) } else { - Image(systemName: "minus.circle.fill") - .foregroundStyle(.red) - Text("\(abs(winnings))") + Text("\(totalWinnings)") + .font(.system(size: totalFontSize, weight: .black, design: .rounded)) .foregroundStyle(.red) } } - .font(.system(size: winningsFontSize, weight: .bold, design: .rounded)) - .scaleEffect(showWinnings ? Design.Scale.normal : Design.Scale.shrunk) - .opacity(showWinnings ? Design.Scale.normal : 0) - } - - // Side bet win descriptions - if !sideBetWinnings.isEmpty { - VStack(spacing: Design.Spacing.xSmall) { - ForEach(sideBetWinnings, id: \.self) { description in - Text(description) - .font(.system(size: sideBetFontSize, weight: .semibold)) - .foregroundStyle(.yellow) - } - } - .padding(.top, Design.Spacing.xSmall) - .scaleEffect(showSideBets ? Design.Scale.normal : Design.Scale.shrunk) - .opacity(showSideBets ? Design.Scale.normal : 0) + .scaleEffect(showTotal ? Design.Scale.normal : Design.Scale.shrunk) + .opacity(showTotal ? Design.Scale.normal : 0) } } - .padding(Design.Spacing.xxxLarge + Design.Spacing.small) + .padding(.horizontal, Design.Spacing.xLarge) + .padding(.vertical, Design.Spacing.xxLarge) .background( - RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge + Design.Spacing.xSmall) + RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge) .fill( LinearGradient( colors: [ @@ -112,7 +126,7 @@ struct ResultBannerView: View { ) ) .overlay( - RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge + Design.Spacing.xSmall) + RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge) .strokeBorder( LinearGradient( colors: [ @@ -140,11 +154,11 @@ struct ResultBannerView: View { } withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay2)) { - showWinnings = true + showBreakdown = true } - withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay2 + 0.1)) { - showSideBets = true + withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay3)) { + showTotal = true } // Announce result to VoiceOver users @@ -168,25 +182,25 @@ struct ResultBannerView: View { description += ". Banker pair" } - // Add winnings - if winnings > 0 { - let format = String(localized: "wonAmountFormat") - description += ". " + String(format: format, winnings.formatted()) - } else if winnings < 0 { - let format = String(localized: "lostAmountFormat") - description += ". " + String(format: format, abs(winnings).formatted()) + // Add bet results + for bet in winningBets { + description += ". \(bet.displayName) won \(bet.payout)" + } + for bet in losingBets { + description += ". \(bet.displayName) lost \(abs(bet.payout))" } - // Add side bet descriptions - for sideBet in sideBetWinnings { - description += ". \(sideBet)" + // Add total + if totalWinnings > 0 { + description += ". Total winnings: \(totalWinnings)" + } else if totalWinnings < 0 { + description += ". Total loss: \(abs(totalWinnings))" } return description } private func announceResult() { - // Post accessibility announcement for screen reader users Task { @MainActor in try? await Task.sleep(for: .milliseconds(500)) AccessibilityNotification.Announcement(accessibilityDescription).post() @@ -194,14 +208,95 @@ struct ResultBannerView: View { } } -/// A small badge showing pair result. +// MARK: - Bet Breakdown View + +private struct BetBreakdownView: View { + let winningBets: [BetResult] + let losingBets: [BetResult] + let pushBets: [BetResult] + let fontSize: CGFloat + + var body: some View { + VStack(spacing: Design.Spacing.xSmall) { + // Winning bets + ForEach(winningBets) { bet in + BetResultRow(bet: bet, fontSize: fontSize) + } + + // Push bets + ForEach(pushBets) { bet in + BetResultRow(bet: bet, fontSize: fontSize) + } + + // Losing bets + ForEach(losingBets) { bet in + BetResultRow(bet: bet, fontSize: fontSize) + } + } + .padding(.horizontal, Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.white.opacity(Design.Opacity.verySubtle)) + ) + } +} + +// MARK: - Bet Result Row + +private struct BetResultRow: View { + let bet: BetResult + let fontSize: CGFloat + + private var statusColor: Color { + if bet.isWin { return .green } + if bet.isLoss { return .red } + return .yellow // Push + } + + private var statusIcon: String { + if bet.isWin { return "checkmark.circle.fill" } + if bet.isLoss { return "xmark.circle.fill" } + return "arrow.left.arrow.right.circle.fill" // Push + } + + private var payoutText: String { + if bet.isWin { return "+\(bet.payout)" } + if bet.isLoss { return "\(bet.payout)" } + return "PUSH" + } + + var body: some View { + HStack(spacing: Design.Spacing.small) { + // Status icon + Image(systemName: statusIcon) + .font(.system(size: fontSize)) + .foregroundStyle(statusColor) + + // Bet name + Text(bet.displayName) + .font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.heavy)) + + Spacer() + + // Payout + Text(payoutText) + .font(.system(size: fontSize, weight: .bold, design: .rounded)) + .foregroundStyle(statusColor) + } + } +} + +// MARK: - Pair Badge + private struct PairBadge: View { let label: String let color: Color var body: some View { Text(label) - .font(.system(size: 11, weight: .bold)) + .font(.system(size: Design.BaseFontSize.callout - Design.Spacing.xxSmall, weight: .bold)) .foregroundStyle(.white) .padding(.horizontal, Design.Spacing.small) .padding(.vertical, Design.Spacing.xxSmall) @@ -257,7 +352,7 @@ struct ConfettiView: View { } } .allowsHitTesting(false) - .accessibilityHidden(true) // Decorative element + .accessibilityHidden(true) } } @@ -267,11 +362,16 @@ struct ConfettiView: View { .ignoresSafeArea() ResultBannerView( - result: .playerWins, - winnings: 500, + result: .playerWins, + totalWinnings: 1500, + betResults: [ + BetResult(type: .player, amount: 1000, payout: 1000), + BetResult(type: .playerPair, amount: 100, payout: 1100), + BetResult(type: .dragonBonusPlayer, amount: 100, payout: 200), + BetResult(type: .tie, amount: 500, payout: -500) + ], playerHadPair: true, - bankerHadPair: false, - sideBetWinnings: ["Dragon Bonus +300"] + bankerHadPair: false ) } } diff --git a/Baccarat/Views/RoadMapView.swift b/Baccarat/Views/RoadMapView.swift index 536e3c1..26b0850 100644 --- a/Baccarat/Views/RoadMapView.swift +++ b/Baccarat/Views/RoadMapView.swift @@ -36,7 +36,7 @@ struct RoadMapView: View { VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { Text("HISTORY") .font(.system(size: historyFontSize, weight: .bold, design: .rounded)) - .foregroundStyle(.white.opacity(0.6)) + .foregroundStyle(.white.opacity(Design.Opacity.accent)) .tracking(1) ScrollView(.horizontal) { diff --git a/Baccarat/Views/RulesHelpView.swift b/Baccarat/Views/RulesHelpView.swift index f07faf8..833bb74 100644 --- a/Baccarat/Views/RulesHelpView.swift +++ b/Baccarat/Views/RulesHelpView.swift @@ -43,7 +43,7 @@ struct RulesHelpView: View { var body: some View { ZStack { // Background - Color.black.opacity(0.9) + Color.black.opacity(Design.Opacity.almostFull) .ignoresSafeArea() VStack(spacing: Design.Spacing.medium) { @@ -72,10 +72,10 @@ struct RulesHelpView: View { Image(systemName: "suit.heart.fill") .foregroundStyle(.red) } - .font(.system(size: 32)) + .font(.system(size: Design.BaseFontSize.title)) Text("BACCARAT") - .font(.system(size: 28, weight: .black, design: .rounded)) + .font(.system(size: Design.BaseFontSize.title - Design.Spacing.xSmall, weight: .black, design: .rounded)) .foregroundStyle( LinearGradient( colors: [.yellow, .orange], @@ -92,7 +92,7 @@ struct RulesHelpView: View { VStack(spacing: 0) { // Page title Text(currentPage.title) - .font(.system(size: 22, weight: .bold, design: .rounded)) + .font(.system(size: Design.BaseFontSize.xxLarge + Design.Spacing.xxSmall, weight: .bold, design: .rounded)) .foregroundStyle(.yellow) .padding(.top, Design.Spacing.large) .padding(.bottom, Design.Spacing.medium) @@ -130,13 +130,13 @@ struct RulesHelpView: View { HStack(spacing: Design.Spacing.medium) { // Previous button Button { - withAnimation(.spring(duration: 0.3)) { + withAnimation(.spring(duration: Design.Animation.quick)) { goToPreviousPage() } } label: { Image(systemName: "chevron.left.circle.fill") - .font(.system(size: 36)) - .foregroundStyle(currentPage.rawValue > 0 ? .yellow : .gray.opacity(0.5)) + .font(.system(size: Design.BaseFontSize.largeTitle)) + .foregroundStyle(currentPage.rawValue > 0 ? .yellow : .gray.opacity(Design.Opacity.medium)) } .disabled(currentPage.rawValue == 0) @@ -145,7 +145,7 @@ struct RulesHelpView: View { dismiss() } label: { Text("BACK TO GAME") - .font(.system(size: 16, weight: .bold)) + .font(.system(size: Design.BaseFontSize.large, weight: .bold)) .foregroundStyle(.black) .padding(.horizontal, Design.Spacing.xLarge) .padding(.vertical, Design.Spacing.medium) @@ -163,13 +163,13 @@ struct RulesHelpView: View { // Next button Button { - withAnimation(.spring(duration: 0.3)) { + withAnimation(.spring(duration: Design.Animation.quick)) { goToNextPage() } } label: { Image(systemName: "chevron.right.circle.fill") - .font(.system(size: 36)) - .foregroundStyle(currentPage.rawValue < RulesPage.allCases.count - 1 ? .green : .gray.opacity(0.5)) + .font(.system(size: Design.BaseFontSize.largeTitle)) + .foregroundStyle(currentPage.rawValue < RulesPage.allCases.count - 1 ? .green : .gray.opacity(Design.Opacity.medium)) } .disabled(currentPage.rawValue >= RulesPage.allCases.count - 1) } @@ -204,10 +204,10 @@ private struct BasicRulesContent: View { ]) Divider() - .background(Color.white.opacity(0.3)) + .background(Color.white.opacity(Design.Opacity.light)) Text("Card Values") - .font(.system(size: 18, weight: .bold)) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) .foregroundStyle(.white) RuleSection(items: [ @@ -219,10 +219,10 @@ private struct BasicRulesContent: View { RuleSection(text: "Hand values are the sum of cards, keeping only the last digit. For example: 7 + 8 = 15, so the hand value is 5.") Divider() - .background(Color.white.opacity(0.3)) + .background(Color.white.opacity(Design.Opacity.light)) Text("Natural Win") - .font(.system(size: 18, weight: .bold)) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) .foregroundStyle(.white) RuleSection(text: "If either hand totals 8 or 9 with the first two cards, it's a \"Natural\" and the round ends immediately.") @@ -238,10 +238,10 @@ private struct ThirdCardRulesContent: View { RuleSection(text: "If neither hand has a Natural, additional cards may be drawn according to fixed rules.") Divider() - .background(Color.white.opacity(0.3)) + .background(Color.white.opacity(Design.Opacity.light)) Text("Player Rules") - .font(.system(size: 18, weight: .bold)) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) .foregroundStyle(.white) RuleSection(items: [ @@ -250,10 +250,10 @@ private struct ThirdCardRulesContent: View { ]) Divider() - .background(Color.white.opacity(0.3)) + .background(Color.white.opacity(Design.Opacity.light)) Text("Banker Rules") - .font(.system(size: 18, weight: .bold)) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) .foregroundStyle(.white) RuleSection(text: "If Player stood (6-7), Banker draws on 0-5 and stands on 6-7.") @@ -271,7 +271,7 @@ private struct ThirdCardRulesContent: View { .padding() .background( RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(Color.black.opacity(0.2)) + .fill(Color.black.opacity(Design.Opacity.hint)) ) } } @@ -281,16 +281,18 @@ private struct BankerRuleRow: View { let bankerTotal: String let action: String + private let labelWidth: CGFloat = 80 + var body: some View { HStack { Text("Banker \(bankerTotal):") - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: Design.BaseFontSize.callout, weight: .semibold)) .foregroundStyle(.yellow) - .frame(width: 80, alignment: .leading) + .frame(width: labelWidth, alignment: .leading) Text(action) - .font(.system(size: 13)) - .foregroundStyle(.white.opacity(0.9)) + .font(.system(size: Design.BaseFontSize.callout)) + .foregroundStyle(.white.opacity(Design.Opacity.almostFull)) } } } @@ -303,10 +305,10 @@ private struct DragonBonusContent: View { RuleSection(text: "The Dragon Bonus is a side bet available for both Player and Banker. It pays based on how the winning hand wins.") Divider() - .background(Color.white.opacity(0.3)) + .background(Color.white.opacity(Design.Opacity.light)) Text("Payout Table") - .font(.system(size: 18, weight: .bold)) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) .foregroundStyle(.white) VStack(spacing: Design.Spacing.xSmall) { @@ -321,14 +323,14 @@ private struct DragonBonusContent: View { .padding() .background( RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(Color.black.opacity(0.2)) + .fill(Color.black.opacity(Design.Opacity.hint)) ) Divider() - .background(Color.white.opacity(0.3)) + .background(Color.white.opacity(Design.Opacity.light)) Text("Important") - .font(.system(size: 18, weight: .bold)) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) .foregroundStyle(.white) RuleSection(items: [ @@ -347,13 +349,13 @@ private struct PayoutRow: View { var body: some View { HStack { Text(condition) - .font(.system(size: 14)) - .foregroundStyle(.white.opacity(0.9)) + .font(.system(size: Design.BaseFontSize.medium)) + .foregroundStyle(.white.opacity(Design.Opacity.almostFull)) Spacer() Text(payout) - .font(.system(size: 14, weight: .bold)) + .font(.system(size: Design.BaseFontSize.medium, weight: .bold)) .foregroundStyle(.yellow) } .padding(.vertical, Design.Spacing.xxSmall) @@ -368,16 +370,16 @@ private struct PairBonusContent: View { RuleSection(text: "Pair Bonus bets are available for both Player and Banker. They pay when the first two cards dealt to that hand form a pair.") Divider() - .background(Color.white.opacity(0.3)) + .background(Color.white.opacity(Design.Opacity.light)) Text("Payout") - .font(.system(size: 18, weight: .bold)) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) .foregroundStyle(.white) HStack { VStack { Text("11:1") - .font(.system(size: 48, weight: .black, design: .rounded)) + .font(.system(size: Design.BaseFontSize.largeTitle + Design.Spacing.medium, weight: .black, design: .rounded)) .foregroundStyle( LinearGradient( colors: [.yellow, .orange], @@ -387,18 +389,18 @@ private struct PairBonusContent: View { ) Text("Pair Pays") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.white.opacity(0.7)) + .font(.system(size: Design.BaseFontSize.medium, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) } .frame(maxWidth: .infinity) } .padding(.vertical, Design.Spacing.medium) Divider() - .background(Color.white.opacity(0.3)) + .background(Color.white.opacity(Design.Opacity.light)) Text("Examples") - .font(.system(size: 18, weight: .bold)) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) .foregroundStyle(.white) RuleSection(items: [ @@ -410,10 +412,10 @@ private struct PairBonusContent: View { RuleSection(text: "Note: Suits are disregarded. Only the rank matters for a pair.") Divider() - .background(Color.white.opacity(0.3)) + .background(Color.white.opacity(Design.Opacity.light)) Text("Tips") - .font(.system(size: 18, weight: .bold)) + .font(.system(size: Design.BaseFontSize.xLarge, weight: .bold)) .foregroundStyle(.white) RuleSection(items: [ @@ -436,14 +438,14 @@ private struct RuleSection: View { VStack(alignment: .leading, spacing: Design.Spacing.small) { if let title = title { Text(title) - .font(.system(size: 16, weight: .semibold)) + .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) .foregroundStyle(.yellow) } if let text = text { Text(text) - .font(.system(size: 14)) - .foregroundStyle(.white.opacity(0.9)) + .font(.system(size: Design.BaseFontSize.medium)) + .foregroundStyle(.white.opacity(Design.Opacity.almostFull)) .fixedSize(horizontal: false, vertical: true) } @@ -453,9 +455,9 @@ private struct RuleSection: View { Text("•") .foregroundStyle(.yellow) Text(item) - .foregroundStyle(.white.opacity(0.9)) + .foregroundStyle(.white.opacity(Design.Opacity.almostFull)) } - .font(.system(size: 14)) + .font(.system(size: Design.BaseFontSize.medium)) } } } diff --git a/Baccarat/Views/SettingsView.swift b/Baccarat/Views/SettingsView.swift index 213227f..b04230b 100644 --- a/Baccarat/Views/SettingsView.swift +++ b/Baccarat/Views/SettingsView.swift @@ -118,7 +118,7 @@ struct SettingsView: View { settings.load() // Revert changes dismiss() } - .foregroundStyle(.white.opacity(0.7)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) } ToolbarItem(placement: .topBarTrailing) { @@ -154,7 +154,7 @@ struct SettingsSection: View { Text(title) .font(.system(size: Design.BaseFontSize.body, weight: .bold, design: .rounded)) .tracking(1) - .foregroundStyle(.white.opacity(0.6)) + .foregroundStyle(.white.opacity(Design.Opacity.accent)) } .padding(.horizontal, Design.Spacing.xSmall) @@ -165,7 +165,7 @@ struct SettingsSection: View { .padding() .background( RoundedRectangle(cornerRadius: Design.CornerRadius.large) - .fill(Color.white.opacity(0.05)) + .fill(Color.white.opacity(Design.Opacity.verySubtle)) ) } .padding(.horizontal) @@ -347,7 +347,7 @@ struct TableLimitsPicker: View { .padding(.vertical, Design.Spacing.xSmall) .background( Capsule() - .fill(selection == limit ? Color.yellow : Color.yellow.opacity(0.2)) + .fill(selection == limit ? Color.yellow : Color.yellow.opacity(Design.Opacity.hint)) ) if selection == limit { diff --git a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift index d43b7e4..ddb7df0 100644 --- a/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift +++ b/CasinoKit/Sources/CasinoKit/Theme/CasinoDesign.swift @@ -49,6 +49,10 @@ public enum CasinoDesign { public static let radiusSmall: CGFloat = 4 public static let radiusMedium: CGFloat = 8 public static let radiusLarge: CGFloat = 12 + + public static let offsetSmall: CGFloat = 1 + public static let offsetMedium: CGFloat = 2 + public static let offsetLarge: CGFloat = 3 } // MARK: - Opacity @@ -56,7 +60,10 @@ public enum CasinoDesign { public enum Opacity { public static let subtle: CGFloat = 0.05 public static let light: CGFloat = 0.2 + public static let quarter: CGFloat = 0.25 public static let medium: CGFloat = 0.5 + public static let accent: CGFloat = 0.6 + public static let strong: CGFloat = 0.7 public static let heavy: CGFloat = 0.8 public static let nearOpaque: CGFloat = 0.95 } @@ -83,6 +90,16 @@ public enum CasinoDesign { /// Card aspect ratio (height = width * this value). public static let cardAspectRatio: CGFloat = 1.4 + + /// Pattern dimensions for decorative elements. + public static let patternSpacing: CGFloat = 12 + public static let patternDiamondSize: CGFloat = 6 + public static let dashLength: CGFloat = 8 + public static let dashGap: CGFloat = 4 + + /// Chip edge stripe dimensions. + public static let chipStripeWidth: CGFloat = 4 + public static let chipStripeInset: CGFloat = 2 } // MARK: - Font Sizes (Base values for @ScaledMetric) diff --git a/CasinoKit/Sources/CasinoKit/Views/Cards/CardView.swift b/CasinoKit/Sources/CasinoKit/Views/Cards/CardView.swift index 5751a55..ab7a4c2 100644 --- a/CasinoKit/Sources/CasinoKit/Views/Cards/CardView.swift +++ b/CasinoKit/Sources/CasinoKit/Views/Cards/CardView.swift @@ -150,8 +150,8 @@ public struct CardFrontView: View { .shadow( color: .black.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusSmall, - x: 2, - y: 2 + x: CasinoDesign.Shadow.offsetMedium, + y: CasinoDesign.Shadow.offsetMedium ) } } @@ -210,8 +210,8 @@ public struct CardBackView: View { .fill( LinearGradient( colors: [ - theme.cardBackPrimaryColor.opacity(0.8), - theme.cardBackSecondaryColor.opacity(0.8) + theme.cardBackPrimaryColor.opacity(CasinoDesign.Opacity.heavy), + theme.cardBackSecondaryColor.opacity(CasinoDesign.Opacity.heavy) ], startPoint: .top, endPoint: .bottom @@ -244,8 +244,8 @@ public struct CardBackView: View { .shadow( color: .black.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusSmall, - x: 2, - y: 2 + x: CasinoDesign.Shadow.offsetMedium, + y: CasinoDesign.Shadow.offsetMedium ) } } @@ -256,8 +256,8 @@ public struct DiamondPatternView: View { public var body: some View { Canvas { context, size in - let spacing: CGFloat = 12 - let diamondSize: CGFloat = 6 + let spacing = CasinoDesign.Size.patternSpacing + let diamondSize = CasinoDesign.Size.patternDiamondSize for row in stride(from: 0, to: size.height, by: spacing) { let offset = Int(row / spacing) % 2 == 0 ? 0 : spacing / 2 @@ -293,7 +293,10 @@ public struct CardPlaceholderView: View { RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.small) .strokeBorder( Color.white.opacity(CasinoDesign.Opacity.light), - style: StrokeStyle(lineWidth: CasinoDesign.LineWidth.medium, dash: [8, 4]) + style: StrokeStyle( + lineWidth: CasinoDesign.LineWidth.medium, + dash: [CasinoDesign.Size.dashLength, CasinoDesign.Size.dashGap] + ) ) .frame(width: width, height: height) .accessibilityLabel(String(localized: "Empty card slot", bundle: .module)) diff --git a/CasinoKit/Sources/CasinoKit/Views/Chips/ChipStackView.swift b/CasinoKit/Sources/CasinoKit/Views/Chips/ChipStackView.swift index 6f7f43d..4cc46a0 100644 --- a/CasinoKit/Sources/CasinoKit/Views/Chips/ChipStackView.swift +++ b/CasinoKit/Sources/CasinoKit/Views/Chips/ChipStackView.swift @@ -96,7 +96,7 @@ public struct ChipOnTableView: View { } private var textFontSize: CGFloat { - amount >= 1000 ? 10 : 11 + amount >= 1000 ? CasinoDesign.BaseFontSize.xSmall : CasinoDesign.BaseFontSize.xSmall + 1 } // MARK: - Accessibility @@ -116,7 +116,7 @@ public struct ChipOnTableView: View { Circle() .fill( RadialGradient( - colors: [chipColor.opacity(0.9), chipColor], + colors: [chipColor.opacity(CasinoDesign.Opacity.nearOpaque), chipColor], center: .topLeading, startRadius: 0, endRadius: gradientEndRadius @@ -145,8 +145,8 @@ public struct ChipOnTableView: View { .shadow( color: .black.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusSmall, - x: 1, - y: 2 + x: CasinoDesign.Shadow.offsetSmall, + y: CasinoDesign.Shadow.offsetMedium ) .overlay(alignment: .topTrailing) { if showMax { diff --git a/CasinoKit/Sources/CasinoKit/Views/Chips/ChipView.swift b/CasinoKit/Sources/CasinoKit/Views/Chips/ChipView.swift index 67b8f68..5829e6a 100644 --- a/CasinoKit/Sources/CasinoKit/Views/Chips/ChipView.swift +++ b/CasinoKit/Sources/CasinoKit/Views/Chips/ChipView.swift @@ -31,9 +31,7 @@ public struct ChipView: View { private let innerCircleRatio: CGFloat = 0.65 private let innerGradientRatio: CGFloat = 0.4 private let textSizeRatio: CGFloat = 0.25 - private let selectionGlowPadding: CGFloat = 6 - private let shadowOffset: CGFloat = 2 - private let shadowOffsetY: CGFloat = 3 + private let selectionGlowPadding: CGFloat = CasinoDesign.Spacing.xSmall + CasinoDesign.Spacing.xxSmall private var colors: ChipColorSet { theme.chipColors(for: denomination) @@ -90,8 +88,8 @@ public struct ChipView: View { .shadow( color: .black.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.LineWidth.thin, - x: 1, - y: 1 + x: CasinoDesign.Shadow.offsetSmall, + y: CasinoDesign.Shadow.offsetSmall ) // Outer border @@ -119,8 +117,8 @@ public struct ChipView: View { .shadow( color: .black.opacity(CasinoDesign.Opacity.medium), radius: isSelected ? CasinoDesign.Shadow.radiusSmall * 2 : CasinoDesign.Shadow.radiusSmall, - x: shadowOffset, - y: shadowOffsetY + x: CasinoDesign.Shadow.offsetMedium, + y: CasinoDesign.Shadow.offsetLarge ) .scaleEffect(isSelected ? CasinoDesign.Scale.selected : CasinoDesign.Scale.normal) .animation(.spring(duration: CasinoDesign.Animation.quick), value: isSelected) @@ -138,19 +136,23 @@ public struct ChipEdgePattern: View { self.stripeColor = stripeColor } + // MARK: - Pattern Constants + + private let stripeCount = 8 + private let stripeLengthRatio: CGFloat = 0.2 + public var body: some View { Canvas { context, size in let center = CGPoint(x: size.width / 2, y: size.height / 2) let radius = min(size.width, size.height) / 2 - let stripeCount = 8 - let stripeWidth: CGFloat = 4 - let stripeLength: CGFloat = radius * 0.2 + let stripeWidth = CasinoDesign.Size.chipStripeWidth + let stripeLength = radius * stripeLengthRatio for i in 0..