diff --git a/Agents.md b/Agents.md index 025f3a7..dea8015 100644 --- a/Agents.md +++ b/Agents.md @@ -360,6 +360,17 @@ Color.BettingZone.dragonBonusLight - If the project requires secrets such as API keys, never include them in the repository. +## Documentation instructions + +- **Always keep each game's `README.md` file up to date** when adding new functionality or making changes that users or developers need to know about. +- Document new features, settings, or gameplay mechanics in the appropriate game's README. +- Update the README when modifying existing behavior that affects how the game works. +- Include any configuration options, keyboard shortcuts, or special interactions. +- If adding a new game to the workspace, create a comprehensive README following the existing games' format. +- README updates should be part of the same commit as the feature/change they document. + + ## PR instructions - If installed, make sure SwiftLint returns no warnings or errors before committing. +- Verify that the game's README.md reflects any new functionality or behavioral changes. diff --git a/Baccarat/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Baccarat/Resources/Localizable.xcstrings index f5fb970..1887a14 100644 --- a/Baccarat/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Baccarat/Resources/Localizable.xcstrings @@ -458,6 +458,7 @@ } }, "Add $%lld more to meet minimum" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1280,6 +1281,7 @@ }, "Deal" : { "comment" : "The label of a button that deals cards in a game.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/Baccarat/Baccarat/Theme/DesignConstants.swift b/Baccarat/Baccarat/Theme/DesignConstants.swift index 853424a..4ec3f7f 100644 --- a/Baccarat/Baccarat/Theme/DesignConstants.swift +++ b/Baccarat/Baccarat/Theme/DesignConstants.swift @@ -49,6 +49,9 @@ enum Design { // Labels static let labelFontSize: CGFloat = 14 static let labelRowHeight: CGFloat = 30 + + // Buttons + static let bettingButtonsContainerHeight: CGFloat = 70 } // MARK: - Card Deal Animation diff --git a/Baccarat/Baccarat/Views/Game/ActionButtonsView.swift b/Baccarat/Baccarat/Views/Game/ActionButtonsView.swift index f3ea689..9cf245e 100644 --- a/Baccarat/Baccarat/Views/Game/ActionButtonsView.swift +++ b/Baccarat/Baccarat/Views/Game/ActionButtonsView.swift @@ -28,8 +28,7 @@ struct ActionButtonsView: View { private let buttonFontSize: CGFloat = Design.BaseFontSize.xLarge private let iconSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall - private let statusFontSize: CGFloat = Design.BaseFontSize.medium - private let containerHeight: CGFloat = 70 + private let containerHeight: CGFloat = Design.Size.bettingButtonsContainerHeight // MARK: - Body @@ -45,8 +44,6 @@ struct ActionButtonsView: View { bettingButtons } else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner { newRoundButton - } else if !gameState.showResultBanner { - dealingIndicator } } .animation(.easeInOut(duration: Design.Animation.quick), value: gameState.currentPhase) @@ -57,104 +54,13 @@ struct ActionButtonsView: View { // MARK: - Private Views private var bettingButtons: some View { - VStack(spacing: Design.Spacing.small) { - // Show hint if main bet is below minimum - if gameState.isMainBetBelowMinimum { - Text(String(localized: "Add $\(gameState.amountNeededForMinimum) more to meet minimum")) - .font(.system(size: Design.BaseFontSize.small, weight: .medium)) - .foregroundStyle(.orange) - .transition(.opacity.combined(with: .scale(scale: 0.8))) - } - - HStack(spacing: Design.Spacing.medium) { - clearButton - dealButton - } - } - .animation(.easeInOut(duration: Design.Animation.standard), value: gameState.isMainBetBelowMinimum) - } - - private var dealingIndicator: some View { - HStack(spacing: Design.Spacing.xSmall) { - ProgressView() - .tint(.white) - .scaleEffect(0.8) - Text("Dealing...") - .font(.system(size: statusFontSize, weight: .medium)) - .foregroundStyle(.white.opacity(Design.Opacity.heavy)) - .lineLimit(1) - .minimumScaleFactor(Design.MinScaleFactor.relaxed) - } - .padding(.horizontal, Design.Spacing.xLarge) - .padding(.vertical, Design.Spacing.medium) - } - - @ViewBuilder - private var clearButton: some View { - if isAccessibilitySize { - Button("Clear", systemImage: "xmark.circle", action: onClear) - .labelStyle(.iconOnly) - .font(.system(size: iconSize, weight: .semibold)) - .foregroundStyle(.white) - .padding(Design.Spacing.medium + Design.Spacing.xxSmall) - .background( - Circle() - .fill(Color.CasinoButton.destructive) - ) - .opacity(gameState.currentBets.isEmpty ? Design.Opacity.disabled : 1.0) - .disabled(gameState.currentBets.isEmpty) - } else { - Button("Clear", systemImage: "xmark.circle", action: onClear) - .labelStyle(.titleAndIcon) - .font(.system(size: buttonFontSize, weight: .semibold)) - .foregroundStyle(.white) - .padding(.horizontal, Design.Spacing.xxLarge) - .padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall) - .background( - Capsule() - .fill(Color.CasinoButton.destructive) - ) - .opacity(gameState.currentBets.isEmpty ? Design.Opacity.disabled : 1.0) - .disabled(gameState.currentBets.isEmpty) - } - } - - @ViewBuilder - private var dealButton: some View { - let buttonBackground = LinearGradient( - colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark], - startPoint: .top, - endPoint: .bottom + BettingActionsView( + hasBet: !gameState.currentBets.isEmpty, + canDeal: gameState.canDeal, + amountNeededForMinimum: gameState.isMainBetBelowMinimum ? gameState.amountNeededForMinimum : nil, + onClear: onClear, + onDeal: onDeal ) - - if isAccessibilitySize { - Button("Deal", systemImage: "play.fill", action: onDeal) - .labelStyle(.iconOnly) - .font(.system(size: iconSize, weight: .bold)) - .foregroundStyle(.black) - .padding(Design.Spacing.medium + Design.Spacing.xxSmall) - .background( - Circle() - .fill(buttonBackground) - ) - .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) - .opacity(gameState.canDeal ? 1.0 : Design.Opacity.disabled) - .disabled(!gameState.canDeal) - } else { - Button("Deal", systemImage: "play.fill", action: onDeal) - .labelStyle(.titleAndIcon) - .font(.system(size: buttonFontSize, weight: .bold)) - .foregroundStyle(.black) - .padding(.horizontal, Design.Spacing.xxLarge) - .padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall) - .background( - Capsule() - .fill(buttonBackground) - ) - .shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium) - .opacity(gameState.canDeal ? 1.0 : Design.Opacity.disabled) - .disabled(!gameState.canDeal) - } } @ViewBuilder diff --git a/Blackjack/Blackjack/Resources/Localizable.xcstrings b/Blackjack/Blackjack/Resources/Localizable.xcstrings index a44b782..ce690ca 100644 --- a/Blackjack/Blackjack/Resources/Localizable.xcstrings +++ b/Blackjack/Blackjack/Resources/Localizable.xcstrings @@ -804,6 +804,7 @@ } }, "Add $%lld more to meet minimum" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2218,6 +2219,7 @@ } }, "Deal" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/Blackjack/Blackjack/Views/Game/ActionButtonsView.swift b/Blackjack/Blackjack/Views/Game/ActionButtonsView.swift index 52fffe1..ae4aa35 100644 --- a/Blackjack/Blackjack/Views/Game/ActionButtonsView.swift +++ b/Blackjack/Blackjack/Views/Game/ActionButtonsView.swift @@ -11,10 +11,6 @@ import CasinoKit struct ActionButtonsView: View { @Bindable var state: GameState - // Scaled metrics - @ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = Design.BaseFontSize.large - @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.IconSize.large - // Scaled container height - base 60pt, scales with accessibility @ScaledMetric(relativeTo: .body) private var containerHeight: CGFloat = 60 @@ -44,40 +40,13 @@ struct ActionButtonsView: View { @ViewBuilder private var bettingButtons: some View { - if state.currentBet > 0 { - VStack(spacing: Design.Spacing.small) { - // Show hint if bet is below minimum - if state.isBetBelowMinimum { - Text(String(localized: "Add $\(state.amountNeededForMinimum) more to meet minimum")) - .font(.system(size: Design.BaseFontSize.small, weight: .medium)) - .foregroundStyle(.orange) - .transition(.opacity.combined(with: .scale(scale: 0.8))) - } - - HStack(spacing: Design.Spacing.medium) { - ActionButton( - String(localized: "Clear"), - icon: "xmark.circle", - style: .destructive - ) { - state.clearBet() - } - - // Always show Deal button, but disable if below minimum - ActionButton( - String(localized: "Deal"), - icon: "play.fill", - style: .primary - ) { - Task { await state.deal() } - } - .opacity(state.canDeal ? 1.0 : Design.Opacity.medium) - .disabled(!state.canDeal) - } - } - .transition(.opacity.combined(with: .scale(scale: 0.9))) - .animation(.easeInOut(duration: Design.Animation.standard), value: state.isBetBelowMinimum) - } + BettingActionsView( + hasBet: state.currentBet > 0, + canDeal: state.canDeal, + amountNeededForMinimum: state.isBetBelowMinimum ? state.amountNeededForMinimum : nil, + onClear: { state.clearBet() }, + onDeal: { Task { await state.deal() } } + ) } // MARK: - Player Turn Buttons diff --git a/CasinoKit/Sources/CasinoKit/Exports.swift b/CasinoKit/Sources/CasinoKit/Exports.swift index 8cdc81f..1960a40 100644 --- a/CasinoKit/Sources/CasinoKit/Exports.swift +++ b/CasinoKit/Sources/CasinoKit/Exports.swift @@ -43,6 +43,7 @@ // MARK: - Buttons // - ActionButton, ActionButtonStyle +// - BettingActionsView (Clear/Deal button pair for betting phase) // MARK: - Zones // - BettingZone diff --git a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings index e942d4d..f8d3290 100644 --- a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings +++ b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings @@ -334,6 +334,29 @@ } } }, + "Add $%lld more to meet minimum" : { + "comment" : "Hint shown when bet is below table minimum. The argument is the dollar amount needed.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add $%lld more to meet minimum" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agrega $%lld más para alcanzar el mínimo" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajoutez %lld $ de plus pour atteindre le minimum" + } + } + } + }, "All game data is stored:" : { "localizations" : { "en" : { @@ -561,6 +584,33 @@ } } }, + "Clear" : { + "comment" : "A button with an \"x\" icon and \"Clear\" label.", + "isCommentAutoGenerated" : true + }, + "Clear bet" : { + "comment" : "Accessibility label for the Clear button that removes all bets.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear bet" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Borrar apuesta" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer la mise" + } + } + } + }, "Clubs" : { "localizations" : { "en" : { @@ -629,6 +679,33 @@ } } }, + "Deal" : { + "comment" : "A button that deals cards when pressed.", + "isCommentAutoGenerated" : true + }, + "Deal cards" : { + "comment" : "Accessibility label for the Deal button that starts dealing cards.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deal cards" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repartir cartas" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distribuer les cartes" + } + } + } + }, "Dealing Speed" : { "localizations" : { "en" : { diff --git a/CasinoKit/Sources/CasinoKit/Views/Buttons/BettingActionsView.swift b/CasinoKit/Sources/CasinoKit/Views/Buttons/BettingActionsView.swift new file mode 100644 index 0000000..8a1046e --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/Buttons/BettingActionsView.swift @@ -0,0 +1,209 @@ +// +// BettingActionsView.swift +// CasinoKit +// +// Reusable Clear/Deal button pair for casino games betting phase. +// + +import SwiftUI + +/// A reusable view that displays Clear and Deal buttons for casino game betting phases. +/// +/// This view follows the "always visible, disabled when unavailable" pattern for better UX: +/// - Buttons are always shown to maintain layout stability +/// - Clear is disabled when no bet is placed +/// - Deal is disabled when the bet doesn't meet requirements +/// - Optional minimum bet hint displays when bet is below minimum +public struct BettingActionsView: View { + + // MARK: - Properties + + /// Whether the player has any bet placed. + public let hasBet: Bool + + /// Whether the deal action is currently available. + public let canDeal: Bool + + /// Amount needed to reach minimum bet (shown as hint when > 0). + public let amountNeededForMinimum: Int? + + /// Action when Clear is tapped. + public let onClear: () -> Void + + /// Action when Deal is tapped. + public let onDeal: () -> Void + + // MARK: - Environment + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + /// Whether the current text size is an accessibility size (very large). + private var isAccessibilitySize: Bool { + dynamicTypeSize.isAccessibilitySize + } + + // MARK: - Layout Constants + + private let buttonFontSize: CGFloat = CasinoDesign.BaseFontSize.xLarge + private let iconSize: CGFloat = CasinoDesign.BaseFontSize.xxLarge + CasinoDesign.Spacing.xSmall + private let hintFontSize: CGFloat = CasinoDesign.BaseFontSize.small + + // MARK: - Initialization + + /// Creates a betting actions view. + /// - Parameters: + /// - hasBet: Whether there is any bet placed. + /// - canDeal: Whether deal is currently available. + /// - amountNeededForMinimum: Optional amount needed to reach minimum (shown as hint). + /// - onClear: Action when Clear is tapped. + /// - onDeal: Action when Deal is tapped. + public init( + hasBet: Bool, + canDeal: Bool, + amountNeededForMinimum: Int? = nil, + onClear: @escaping () -> Void, + onDeal: @escaping () -> Void + ) { + self.hasBet = hasBet + self.canDeal = canDeal + self.amountNeededForMinimum = amountNeededForMinimum + self.onClear = onClear + self.onDeal = onDeal + } + + // MARK: - Body + + public var body: some View { + VStack(spacing: CasinoDesign.Spacing.small) { + // Show hint if bet is below minimum + if let amount = amountNeededForMinimum, amount > 0 { + Text(String(localized: "Add $\(amount) more to meet minimum", bundle: .module)) + .font(.system(size: hintFontSize, weight: .medium)) + .foregroundStyle(.orange) + .transition(.opacity.combined(with: .scale(scale: 0.8))) + } + + HStack(spacing: CasinoDesign.Spacing.medium) { + clearButton + dealButton + } + } + .animation(.easeInOut(duration: CasinoDesign.Animation.standard), value: amountNeededForMinimum) + } + + // MARK: - Private Views + + @ViewBuilder + private var clearButton: some View { + let isEnabled = hasBet + + if isAccessibilitySize { + Button("Clear", systemImage: "xmark.circle", action: onClear) + .labelStyle(.iconOnly) + .font(.system(size: iconSize, weight: .semibold)) + .foregroundStyle(.white) + .padding(CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall) + .background( + Circle() + .fill(Color.CasinoButton.destructive) + ) + .opacity(isEnabled ? 1.0 : CasinoDesign.Opacity.disabled) + .disabled(!isEnabled) + .accessibilityLabel(String(localized: "Clear bet", bundle: .module)) + } else { + Button("Clear", systemImage: "xmark.circle", action: onClear) + .labelStyle(.titleAndIcon) + .font(.system(size: buttonFontSize, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, CasinoDesign.Spacing.xxLarge) + .padding(.vertical, CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall) + .background( + Capsule() + .fill(Color.CasinoButton.destructive) + ) + .opacity(isEnabled ? 1.0 : CasinoDesign.Opacity.disabled) + .disabled(!isEnabled) + .accessibilityLabel(String(localized: "Clear bet", bundle: .module)) + } + } + + @ViewBuilder + private var dealButton: some View { + let buttonBackground = LinearGradient( + colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark], + startPoint: .top, + endPoint: .bottom + ) + + if isAccessibilitySize { + Button("Deal", systemImage: "play.fill", action: onDeal) + .labelStyle(.iconOnly) + .font(.system(size: iconSize, weight: .bold)) + .foregroundStyle(.black) + .padding(CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall) + .background( + Circle() + .fill(buttonBackground) + ) + .shadow(color: .yellow.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusMedium) + .opacity(canDeal ? 1.0 : CasinoDesign.Opacity.disabled) + .disabled(!canDeal) + .accessibilityLabel(String(localized: "Deal cards", bundle: .module)) + } else { + Button("Deal", systemImage: "play.fill", action: onDeal) + .labelStyle(.titleAndIcon) + .font(.system(size: buttonFontSize, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, CasinoDesign.Spacing.xxLarge) + .padding(.vertical, CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall) + .background( + Capsule() + .fill(buttonBackground) + ) + .shadow(color: .yellow.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusMedium) + .opacity(canDeal ? 1.0 : CasinoDesign.Opacity.disabled) + .disabled(!canDeal) + .accessibilityLabel(String(localized: "Deal cards", bundle: .module)) + } + } +} + +// MARK: - Previews + +#Preview("No Bet Placed") { + ZStack { + Color.CasinoTable.felt.ignoresSafeArea() + BettingActionsView( + hasBet: false, + canDeal: false, + onClear: {}, + onDeal: {} + ) + } +} + +#Preview("Bet Below Minimum") { + ZStack { + Color.CasinoTable.felt.ignoresSafeArea() + BettingActionsView( + hasBet: true, + canDeal: false, + amountNeededForMinimum: 25, + onClear: {}, + onDeal: {} + ) + } +} + +#Preview("Ready to Deal") { + ZStack { + Color.CasinoTable.felt.ignoresSafeArea() + BettingActionsView( + hasBet: true, + canDeal: true, + onClear: {}, + onDeal: {} + ) + } +} +