Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
547f690e3c
commit
62c1cf4daf
11
Agents.md
11
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.
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -43,6 +43,7 @@
|
||||
|
||||
// MARK: - Buttons
|
||||
// - ActionButton, ActionButtonStyle
|
||||
// - BettingActionsView (Clear/Deal button pair for betting phase)
|
||||
|
||||
// MARK: - Zones
|
||||
// - BettingZone
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user