diff --git a/Baccarat/Baccarat/Engine/BaccaratEngine.swift b/Baccarat/Baccarat/Engine/BaccaratEngine.swift index 408753a..48183ff 100644 --- a/Baccarat/Baccarat/Engine/BaccaratEngine.swift +++ b/Baccarat/Baccarat/Engine/BaccaratEngine.swift @@ -208,11 +208,12 @@ struct BaccaratEngine { /// - Parameters: /// - bet: The bet that was placed. /// - result: The result of the round. + /// - variant: The baccarat variant being played (affects Banker payouts). /// - Returns: The net winnings (positive), net loss (negative), or 0 for push. - func calculatePayout(bet: Bet, result: GameResult) -> Int { + func calculatePayout(bet: Bet, result: GameResult, variant: BaccaratVariant = .standard) -> Int { switch bet.type { case .player, .banker, .tie: - return calculateMainBetPayout(bet: bet, result: result) + return calculateMainBetPayout(bet: bet, result: result, variant: variant) case .playerPair: return playerHasPair ? Int(Double(bet.amount) * bet.type.payoutMultiplier) : -bet.amount @@ -229,15 +230,23 @@ struct BaccaratEngine { } /// Calculates payout for main bets (Player, Banker, Tie). - private func calculateMainBetPayout(bet: Bet, result: GameResult) -> Int { + private func calculateMainBetPayout(bet: Bet, result: GameResult, variant: BaccaratVariant) -> Int { + // Standard push logic (e.g., Tie on Player/Banker bets) if result.isPush(for: bet.type) { - // Push - bet is returned return 0 } + // Commission-Free: Banker wins with 6 is a push + if variant == .commissionFree + && bet.type == .banker + && result == .bankerWins + && bankerHand.value == 6 { + return 0 // Push - bet returned + } + if result.isWinningBet(bet.type) { - // Win - return winnings based on payout multiplier - return Int(Double(bet.amount) * bet.type.payoutMultiplier) + // Win - return winnings based on variant-aware payout multiplier + return Int(Double(bet.amount) * bet.type.payoutMultiplier(for: variant)) } else { // Loss - lose the bet amount return -bet.amount diff --git a/Baccarat/Baccarat/Engine/GameState.swift b/Baccarat/Baccarat/Engine/GameState.swift index 44dd11d..41d7054 100644 --- a/Baccarat/Baccarat/Engine/GameState.swift +++ b/Baccarat/Baccarat/Engine/GameState.swift @@ -938,7 +938,7 @@ final class GameState: CasinoGameState { var dragonBankerWon = false for bet in currentBets { - let payout = engine.calculatePayout(bet: bet, result: result) + let payout = engine.calculatePayout(bet: bet, result: result, variant: settings.gameVariant) totalWinnings += payout // Track individual bet result diff --git a/Baccarat/Baccarat/Models/BetType.swift b/Baccarat/Baccarat/Models/BetType.swift index 69d1790..5aaa4c4 100644 --- a/Baccarat/Baccarat/Models/BetType.swift +++ b/Baccarat/Baccarat/Models/BetType.swift @@ -45,6 +45,19 @@ enum BetType: String, CaseIterable, Identifiable { } } + /// Returns the payout multiplier for this bet type based on the game variant. + /// - Parameter variant: The baccarat variant being played. + /// - Returns: The payout multiplier (e.g., 1.0 for 1:1, 0.95 for 5% commission). + func payoutMultiplier(for variant: BaccaratVariant) -> Double { + switch self { + case .banker: + // Commission-Free pays 1:1 (Banker 6 push handled separately in engine) + return variant == .commissionFree ? 1.0 : 0.95 + default: + return payoutMultiplier + } + } + /// Display name with payout info. var displayWithPayout: String { switch self { @@ -82,6 +95,18 @@ enum BetType: String, CaseIterable, Identifiable { } } + /// Returns the payout description for this bet type based on the game variant. + /// - Parameter variant: The baccarat variant being played. + /// - Returns: The payout description string for display. + func payoutDescription(for variant: BaccaratVariant) -> String { + switch self { + case .banker: + return variant == .commissionFree ? "PAYS 1 TO 1" : "PAYS 0.95 TO 1" + default: + return payoutDescription + } + } + /// The color associated with this bet type. var color: Color { switch self { diff --git a/Baccarat/Baccarat/Models/GameSettings.swift b/Baccarat/Baccarat/Models/GameSettings.swift index 0e0626f..a9d3bc9 100644 --- a/Baccarat/Baccarat/Models/GameSettings.swift +++ b/Baccarat/Baccarat/Models/GameSettings.swift @@ -67,6 +67,28 @@ enum RevealStyle: String, CaseIterable, Codable, Identifiable { } } +/// The baccarat game variant. +enum BaccaratVariant: String, CaseIterable, Identifiable, Codable { + case standard = "standard" + case commissionFree = "commissionFree" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .standard: return String(localized: "Standard (5% Commission)") + case .commissionFree: return String(localized: "Commission Free") + } + } + + var description: String { + switch self { + case .standard: return String(localized: "Banker pays 0.95:1") + case .commissionFree: return String(localized: "Banker pays 1:1, but Banker 6 pushes") + } + } +} + // TableLimits is now provided by CasinoKit /// Observable settings class for Baccarat configuration. @@ -129,6 +151,11 @@ final class GameSettings: GameSettingsProtocol { /// Whether to show streak alerts. var showStreakAlerts: Bool = true + // MARK: - Game Variant Settings + + /// The baccarat game variant (standard vs commission-free). + var gameVariant: BaccaratVariant = .standard + // MARK: - Sound Settings /// Whether sound effects are enabled. @@ -157,6 +184,7 @@ final class GameSettings: GameSettingsProtocol { static let revealStyle = "settings.revealStyle" static let preferredRoadType = "settings.preferredRoadType" static let showStreakAlerts = "settings.showStreakAlerts" + static let gameVariant = "settings.gameVariant" } // MARK: - iCloud @@ -286,6 +314,11 @@ final class GameSettings: GameSettingsProtocol { if defaults.object(forKey: Keys.showStreakAlerts) != nil { self.showStreakAlerts = defaults.bool(forKey: Keys.showStreakAlerts) } + + if let rawVariant = defaults.string(forKey: Keys.gameVariant), + let variant = BaccaratVariant(rawValue: rawVariant) { + self.gameVariant = variant + } } /// Loads settings from iCloud. @@ -351,6 +384,11 @@ final class GameSettings: GameSettingsProtocol { if store.object(forKey: Keys.showStreakAlerts) != nil { self.showStreakAlerts = store.bool(forKey: Keys.showStreakAlerts) } + + if let rawVariant = store.string(forKey: Keys.gameVariant), + let variant = BaccaratVariant(rawValue: rawVariant) { + self.gameVariant = variant + } } /// Saves settings to UserDefaults and iCloud. @@ -371,6 +409,7 @@ final class GameSettings: GameSettingsProtocol { defaults.set(revealStyle.rawValue, forKey: Keys.revealStyle) defaults.set(preferredRoadType.rawValue, forKey: Keys.preferredRoadType) defaults.set(showStreakAlerts, forKey: Keys.showStreakAlerts) + defaults.set(gameVariant.rawValue, forKey: Keys.gameVariant) // Also save to iCloud if iCloudAvailable, let store = iCloudStore { @@ -388,6 +427,7 @@ final class GameSettings: GameSettingsProtocol { store.set(revealStyle.rawValue, forKey: Keys.revealStyle) store.set(preferredRoadType.rawValue, forKey: Keys.preferredRoadType) store.set(showStreakAlerts, forKey: Keys.showStreakAlerts) + store.set(gameVariant.rawValue, forKey: Keys.gameVariant) store.synchronize() } } @@ -426,6 +466,7 @@ final class GameSettings: GameSettingsProtocol { revealStyle = .auto preferredRoadType = .big showStreakAlerts = true + gameVariant = .standard save() } } diff --git a/Baccarat/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Baccarat/Resources/Localizable.xcstrings index 737b7c1..3a867ee 100644 --- a/Baccarat/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Baccarat/Resources/Localizable.xcstrings @@ -432,6 +432,10 @@ } } }, + "6 PUSHES" : { + "comment" : "A note displayed for Banker 6 pushes in the baccarat betting table.", + "isCommentAutoGenerated" : true + }, "8 : 1" : { "comment" : "The payout ratio for a tie bet.", "localizations" : { @@ -671,6 +675,9 @@ } } } + }, + "An alternative to standard Baccarat with simplified payouts." : { + }, "Analyzes patterns from the Big Road." : { "comment" : "Tooltip text for the \"Big Eye Boy\" road type in the road map interface.", @@ -1094,6 +1101,9 @@ } } } + }, + "Banker Bet: Pays 1:1 (no 5% commission)" : { + }, "Banker hand" : { "comment" : "A label displayed above the banker's hand.", @@ -1141,6 +1151,14 @@ } } }, + "Banker pays 0.95:1" : { + "comment" : "Description of the baccarat variant where the banker pays 0.95:1.", + "isCommentAutoGenerated" : true + }, + "Banker pays 1:1, but Banker 6 pushes" : { + "comment" : "Description of the \"Commission Free\" baccarat variant.", + "isCommentAutoGenerated" : true + }, "Banker running hot (%lld%%)" : { "comment" : "A hint to place a bet on the Banker based on the calculated house edge. The argument is the percentage of the house edge.", "localizations" : { @@ -1257,6 +1275,10 @@ } } }, + "Betting Hint" : { + "comment" : "An accessibility label for the combined trend and hint view.", + "isCommentAutoGenerated" : true + }, "Betting tips and trend analysis" : { "comment" : "Description for hints feature.", "localizations" : { @@ -1722,6 +1744,13 @@ "Color Meaning" : { "comment" : "A heading displayed in the color legend of a derived road type popover.", "isCommentAutoGenerated" : true + }, + "Commission Free" : { + "comment" : "Description of a baccarat game variant where the banker always wins, regardless of the player's hand.", + "isCommentAutoGenerated" : true + }, + "Commission-Free Mode" : { + }, "Compares current streak to 2 columns back." : { "comment" : "Tooltip text for the \"Small Road\" road type in the Road Map selector.", @@ -2017,6 +2046,9 @@ } } } + }, + "Enable in Settings → Game Variant." : { + }, "End Session" : { "comment" : "The text for a button that ends the current game session.", @@ -2086,6 +2118,9 @@ } } } + }, + "EXCEPT: If Banker wins with a total of 6, it's a push." : { + }, "Finest pattern detection. Looks 3 columns back." : { "comment" : "Short description for a road type that compares the current streak to one from three columns ago.", @@ -2159,6 +2194,9 @@ } } } + }, + "GAME VARIANT" : { + }, "Generate & Save Icons" : { "comment" : "A button label that triggers the generation of app icons.", @@ -2419,6 +2457,9 @@ } } } + }, + "House edge is slightly higher (1.46%) but payouts are easier." : { + }, "How to Export Icons" : { "comment" : "A section header explaining how to export app icons.", @@ -3460,6 +3501,9 @@ } } } + }, + "Player and Tie bets pay the same as standard Baccarat." : { + }, "Player Bet: Pays 1:1 (even money)" : { "comment" : "Description of the payout for a Player Bet in the Rules Help view.", @@ -4447,6 +4491,10 @@ "comment" : "Name of the reveal style option that uses pressure-based card peeking.", "isCommentAutoGenerated" : true }, + "Standard (5% Commission)" : { + "comment" : "Description of the \"Standard\" baccarat variant, including the commission rate.", + "isCommentAutoGenerated" : true + }, "STARTING BALANCE" : { "comment" : "Section header for starting balance settings.", "localizations" : { @@ -5804,6 +5852,9 @@ } } } + }, + "Your Banker bet is returned — you don't win or lose." : { + } }, "version" : "1.1" diff --git a/Baccarat/Baccarat/Views/Game/GameTableView.swift b/Baccarat/Baccarat/Views/Game/GameTableView.swift index 97e9d2d..684d182 100644 --- a/Baccarat/Baccarat/Views/Game/GameTableView.swift +++ b/Baccarat/Baccarat/Views/Game/GameTableView.swift @@ -324,14 +324,6 @@ struct GameTableView: View, SherpaDelegate { // Betting table - completely hidden during dealing if !isDealing { - // Trend badge when there's an active streak - TrendBadgeView( - streakType: state.currentStreakInfo.type, - streakCount: state.currentStreakInfo.count, - minimumStreak: 2 - ) - .padding(.bottom, Design.Spacing.xSmall) - BettingTableView( gameState: state, selectedChip: selectedChip @@ -341,17 +333,15 @@ struct GameTableView: View, SherpaDelegate { .transition(.opacity) .debugBorder(showDebugBorders, color: .blue, label: "BetTable") - // Betting hint (static, below table, above chips) - if let hintInfo = state.currentHintInfo { - BettingHintView( - hint: hintInfo.text, - secondaryInfo: hintInfo.secondaryText, - style: hintInfo.style - ) - .transition(.opacity) - .padding(.vertical, Design.Spacing.small) - .debugBorder(showDebugBorders, color: .purple, label: "Hint") - } + // Combined trend and hint view (static, below table, above chips) + CombinedTrendHintView( + streakType: state.currentStreakInfo.type, + streakCount: state.currentStreakInfo.count, + hintInfo: state.currentHintInfo + ) + .transition(.opacity) + .padding(.vertical, Design.Spacing.small) + .debugBorder(showDebugBorders, color: .purple, label: "TrendHint") } Spacer(minLength: Design.Spacing.xSmall) @@ -458,14 +448,6 @@ struct GameTableView: View, SherpaDelegate { // Betting table - completely hidden during dealing if !isDealing { - // Trend badge when there's an active streak - TrendBadgeView( - streakType: state.currentStreakInfo.type, - streakCount: state.currentStreakInfo.count, - minimumStreak: 2 - ) - .padding(.bottom, Design.Spacing.xSmall) - BettingTableView( gameState: state, selectedChip: selectedChip @@ -475,17 +457,15 @@ struct GameTableView: View, SherpaDelegate { .transition(.opacity) .debugBorder(showDebugBorders, color: .blue, label: "BetTable") - // Betting hint (static, below table, above chips) - if let hintInfo = state.currentHintInfo { - BettingHintView( - hint: hintInfo.text, - secondaryInfo: hintInfo.secondaryText, - style: hintInfo.style - ) - .transition(.opacity) - .padding(.vertical, Design.Spacing.small) - .debugBorder(showDebugBorders, color: .purple, label: "Hint") - } + // Combined trend and hint view (static, below table, above chips) + CombinedTrendHintView( + streakType: state.currentStreakInfo.type, + streakCount: state.currentStreakInfo.count, + hintInfo: state.currentHintInfo + ) + .transition(.opacity) + .padding(.vertical, Design.Spacing.small) + .debugBorder(showDebugBorders, color: .purple, label: "TrendHint") } // Chip selector - only shown during betting phase diff --git a/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift b/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift index 06cbce3..e6e49ca 100644 --- a/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift +++ b/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift @@ -65,6 +65,19 @@ struct RulesHelpView: View { String(localized: "Banker bet has the lowest house edge (1.06%).") ] ), + RulePage( + title: String(localized: "Commission-Free Mode"), + icon: "sparkles", + content: [ + String(localized: "An alternative to standard Baccarat with simplified payouts."), + String(localized: "Banker Bet: Pays 1:1 (no 5% commission)"), + String(localized: "EXCEPT: If Banker wins with a total of 6, it's a push."), + String(localized: "Your Banker bet is returned — you don't win or lose."), + String(localized: "Player and Tie bets pay the same as standard Baccarat."), + String(localized: "House edge is slightly higher (1.46%) but payouts are easier."), + String(localized: "Enable in Settings → Game Variant.") + ] + ), RulePage( title: String(localized: "Third Card - Player"), icon: "hand.draw.fill", diff --git a/Baccarat/Baccarat/Views/Sheets/SettingsView.swift b/Baccarat/Baccarat/Views/Sheets/SettingsView.swift index c87401a..de76265 100644 --- a/Baccarat/Baccarat/Views/Sheets/SettingsView.swift +++ b/Baccarat/Baccarat/Views/Sheets/SettingsView.swift @@ -41,7 +41,15 @@ struct SettingsView: View { } } - // 2. Deck Settings + // 2. Game Variant + SheetSection(title: String(localized: "GAME VARIANT"), icon: "sparkles") { + GameVariantPicker(selection: $settings.gameVariant) + .onChange(of: settings.gameVariant) { _, _ in + hasChanges = true + } + } + + // 3. Deck Settings SheetSection(title: String(localized: "DECK SETTINGS"), icon: "rectangle.portrait.on.rectangle.portrait") { DeckCountPicker(selection: $settings.deckCount) .onChange(of: settings.deckCount) { _, _ in @@ -476,6 +484,26 @@ struct RevealStylePicker: View { } } +// MARK: - Game Variant Picker (Baccarat-specific) + +struct GameVariantPicker: View { + @Binding var selection: BaccaratVariant + + var body: some View { + VStack(spacing: Design.Spacing.small) { + ForEach(BaccaratVariant.allCases) { variant in + SelectableRow( + title: variant.displayName, + subtitle: variant.description, + isSelected: selection == variant, + accentColor: Color.Sheet.accent, + action: { selection = variant } + ) + } + } + } +} + #Preview { SettingsView(settings: GameSettings(), gameState: GameState()) { } } diff --git a/Baccarat/Baccarat/Views/Table/BettingTableView.swift b/Baccarat/Baccarat/Views/Table/BettingTableView.swift index 71e1042..35cd3c6 100644 --- a/Baccarat/Baccarat/Views/Table/BettingTableView.swift +++ b/Baccarat/Baccarat/Views/Table/BettingTableView.swift @@ -68,6 +68,11 @@ struct BettingTableView: View { ) } + /// Banker payout text depends on game variant + private var bankerPayoutText: String { + gameState.settings.gameVariant == .commissionFree ? "1 : 1" : "0.95 : 1" + } + // Use global debug flag from Design constants private var showDebugBorders: Bool { Design.showDebugBorders } @@ -112,7 +117,7 @@ struct BettingTableView: View { // Middle row: BANKER | BONUS MainBetRow( title: "BANKER", - payoutText: "0.95 : 1", + payoutText: bankerPayoutText, mainBetAmount: betAmount(for: .banker), bonusBetAmount: betAmount(for: .dragonBonusBanker), isSelected: isBankerSelected, @@ -122,6 +127,7 @@ struct BettingTableView: View { isBonusAtMax: isAtMax(for: .dragonBonusBanker), mainColor: Color.BettingZone.bankerDark, rowHeight: mainRowHeight, + showBanker6Note: gameState.settings.gameVariant == .commissionFree, onMain: { gameState.placeBet(type: .banker, amount: selectedChip.rawValue) }, onBonus: { gameState.placeBet(type: .dragonBonusBanker, amount: selectedChip.rawValue) } ) @@ -145,6 +151,7 @@ struct BettingTableView: View { isBonusAtMax: isAtMax(for: .dragonBonusPlayer), mainColor: Color.BettingZone.playerDark, rowHeight: mainRowHeight, + showBanker6Note: false, onMain: { gameState.placeBet(type: .player, amount: selectedChip.rawValue) }, onBonus: { gameState.placeBet(type: .dragonBonusPlayer, amount: selectedChip.rawValue) } ) @@ -337,6 +344,7 @@ private struct MainBetRow: View { let isBonusAtMax: Bool let mainColor: Color let rowHeight: CGFloat + let showBanker6Note: Bool let onMain: () -> Void let onBonus: () -> Void @@ -351,6 +359,7 @@ private struct MainBetRow: View { isEnabled: canBetMain, isAtMax: isMainAtMax, color: mainColor, + showBanker6Note: showBanker6Note, action: onMain ) @@ -382,8 +391,24 @@ private struct MainBetZone: View { let isEnabled: Bool let isAtMax: Bool let color: Color + let showBanker6Note: Bool let action: () -> Void + /// Accessibility label with Banker 6 note if applicable + private var accessibilityLabelText: String { + var label = "\(title) bet, pays \(payoutText)" + if showBanker6Note { + label += ", Banker 6 pushes" + } + if isSelected { + label += ", selected" + } + if betAmount > 0 { + label += ", current bet $\(betAmount)" + } + return label + } + var body: some View { Button { if isEnabled { action() } @@ -407,12 +432,27 @@ private struct MainBetZone: View { Text(title) .font(.system(size: Design.BaseFontSize.xLarge, weight: .black, design: .rounded)) .tracking(2) + .foregroundStyle(.white) - Text(payoutText) + // Payout line - combined with 6 pushes note for Commission-Free + if showBanker6Note { + HStack(spacing: Design.Spacing.xSmall) { + Text(payoutText) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + + Text("•") + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + + Text("6 PUSHES") + .foregroundStyle(.yellow.opacity(Design.Opacity.heavy)) + } .font(.system(size: Design.BaseFontSize.callout - 2, weight: .semibold, design: .rounded)) - .opacity(Design.Opacity.strong) + } else { + Text(payoutText) + .font(.system(size: Design.BaseFontSize.callout - 2, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + } } - .foregroundStyle(.white) // Chip indicator - overlaid on right, doesn't affect centering if betAmount > 0 { @@ -426,7 +466,7 @@ private struct MainBetZone: View { } .buttonStyle(.plain) .accessibilityElement(children: .ignore) - .accessibilityLabel("\(title) bet, pays \(payoutText)" + (isSelected ? ", selected" : "") + (betAmount > 0 ? ", current bet $\(betAmount)" : "")) + .accessibilityLabel(accessibilityLabelText) .accessibilityAddTraits(.isButton) } } diff --git a/Baccarat/Baccarat/Views/Table/HintViews.swift b/Baccarat/Baccarat/Views/Table/HintViews.swift index 0414758..203cc75 100644 --- a/Baccarat/Baccarat/Views/Table/HintViews.swift +++ b/Baccarat/Baccarat/Views/Table/HintViews.swift @@ -167,8 +167,202 @@ struct StreakBadgeView: View { } } +// MARK: - Combined Trend and Hint View + +/// A combined view showing streak information and betting hints side-by-side. +/// Displays streak badge on the left (when present) and hint text on the right. +struct CombinedTrendHintView: View { + /// The current streak type (nil if no streak). + let streakType: GameResult? + + /// The streak count. + let streakCount: Int + + /// Minimum streak to display. + var minimumStreak: Int = 2 + + /// The hint information (nil if no hint to show). + let hintInfo: GameState.HintInfo? + + // MARK: - Scaled Metrics + + @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = CasinoDesign.HintSize.iconSize + @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = CasinoDesign.HintSize.fontSize + @ScaledMetric(relativeTo: .body) private var paddingH: CGFloat = CasinoDesign.HintSize.paddingH + @ScaledMetric(relativeTo: .body) private var paddingV: CGFloat = CasinoDesign.HintSize.paddingV + @ScaledMetric(relativeTo: .caption) private var streakFontSize: CGFloat = Design.BaseFontSize.small + + // MARK: - Computed Properties + + private var shouldShowStreak: Bool { + streakType != nil && streakType != .tie && streakCount >= minimumStreak + } + + private var hasContent: Bool { + shouldShowStreak || hintInfo != nil + } + + private var streakColor: Color { + guard let type = streakType else { return .clear } + return type == .bankerWins ? .red : .blue + } + + private var streakText: String { + guard let type = streakType else { return "" } + let letter = type == .bankerWins ? "B" : "P" + return "\(letter)×\(streakCount)" + } + + private var isHotStreak: Bool { + streakCount >= 4 + } + + // MARK: - Body + + var body: some View { + if hasContent { + HStack(spacing: CasinoDesign.Spacing.small) { + // Streak badge (left side) + if shouldShowStreak { + streakBadge + + // Separator when both are shown + if hintInfo != nil { + Text("•") + .font(.system(size: fontSize, weight: .bold)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + } + + // Hint content (right side) + if let hint = hintInfo { + hintContent(hint: hint) + } + } + .padding(.horizontal, paddingH) + .padding(.vertical, paddingV) + .frame(minWidth: CasinoDesign.HintSize.minWidth, alignment: .center) + .background( + Capsule() + .fill(Color.black.opacity(CasinoDesign.Opacity.heavy)) + .overlay( + Capsule() + .strokeBorder(borderColor.opacity(CasinoDesign.Opacity.medium), lineWidth: CasinoDesign.LineWidth.thin) + ) + ) + .shadow(color: .black.opacity(CasinoDesign.Opacity.medium), radius: CasinoDesign.Shadow.radiusMedium) + .accessibilityElement(children: .combine) + } + } + + // MARK: - Subviews + + private var streakBadge: some View { + HStack(spacing: Design.Spacing.xxSmall) { + Image(systemName: "flame.fill") + .font(.system(size: streakFontSize)) + .foregroundStyle(isHotStreak ? .yellow : .orange) + + Text(streakText) + .font(.system(size: streakFontSize, weight: .bold, design: .rounded)) + .foregroundStyle(streakColor) + } + .accessibilityLabel(streakAccessibilityLabel) + } + + private func hintContent(hint: GameState.HintInfo) -> some View { + HStack(spacing: CasinoDesign.Spacing.small) { + Image(systemName: hint.style.icon) + .font(.system(size: iconSize)) + .foregroundStyle(hint.style.color) + + VStack(alignment: .leading, spacing: CasinoDesign.Spacing.xxSmall) { + Text(hint.text) + .font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(CasinoDesign.MinScaleFactor.comfortable) + + if let secondary = hint.secondaryText { + Text(secondary) + .font(.system(size: fontSize - 2, weight: .regular)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium)) + .lineLimit(1) + } + } + } + .accessibilityLabel(String(localized: "Betting Hint")) + .accessibilityValue(hint.text) + } + + // MARK: - Helpers + + private var borderColor: Color { + if shouldShowStreak { + return streakColor + } else if let hint = hintInfo { + return hint.style.color + } + return .white + } + + private var streakAccessibilityLabel: String { + guard let type = streakType else { return "" } + let sideName = type == .bankerWins ? + String(localized: "Banker") : String(localized: "Player") + return String(localized: "\(sideName) streak of \(streakCount)") + } +} + // MARK: - Previews +#Preview("Combined - Both") { + ZStack { + Color.Table.felt.ignoresSafeArea() + CombinedTrendHintView( + streakType: .bankerWins, + streakCount: 5, + hintInfo: GameState.HintInfo( + text: "Banker running hot (65%)", + secondaryText: nil, + isStreak: false, + isChoppy: false, + isBankerHot: true, + isPlayerHot: false + ) + ) + } +} + +#Preview("Combined - Streak Only") { + ZStack { + Color.Table.felt.ignoresSafeArea() + CombinedTrendHintView( + streakType: .playerWins, + streakCount: 4, + hintInfo: nil + ) + } +} + +#Preview("Combined - Hint Only") { + ZStack { + Color.Table.felt.ignoresSafeArea() + CombinedTrendHintView( + streakType: nil, + streakCount: 0, + hintInfo: GameState.HintInfo( + text: "Banker has the lowest house edge", + secondaryText: nil, + isStreak: false, + isChoppy: false, + isBankerHot: false, + isPlayerHot: false + ) + ) + } +} + #Preview("Distribution Bar") { ZStack { Color.Table.felt.ignoresSafeArea() diff --git a/CasinoKit/Sources/CasinoKit/Views/BettingHintView.swift b/CasinoKit/Sources/CasinoKit/Views/BettingHintView.swift index 255d34b..099f23e 100644 --- a/CasinoKit/Sources/CasinoKit/Views/BettingHintView.swift +++ b/CasinoKit/Sources/CasinoKit/Views/BettingHintView.swift @@ -22,7 +22,7 @@ public enum BettingHintStyle { /// Custom color case custom(Color, String) - var color: Color { + public var color: Color { switch self { case .positive: return .green case .negative: return .red @@ -33,7 +33,7 @@ public enum BettingHintStyle { } } - var icon: String { + public var icon: String { switch self { case .positive: return "arrow.up.circle.fill" case .negative: return "arrow.down.circle.fill"