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

This commit is contained in:
Matt Bruce 2025-12-28 16:12:51 -06:00
parent 547f690e3c
commit 62c1cf4daf
9 changed files with 319 additions and 139 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -43,6 +43,7 @@
// MARK: - Buttons
// - ActionButton, ActionButtonStyle
// - BettingActionsView (Clear/Deal button pair for betting phase)
// MARK: - Zones
// - BettingZone

View File

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

View File

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