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

This commit is contained in:
Matt Bruce 2025-12-24 12:19:59 -06:00
parent 1dc64ebf69
commit 2cd2946a80
6 changed files with 4638 additions and 4606 deletions

View File

@ -53,6 +53,9 @@ final class GameState {
/// Whether to show side bet toast notifications.
var showSideBetToasts: Bool = false
/// Whether to show the gameplay hint toast.
var showHintToast: Bool = false
/// Whether a reshuffle notification should be shown.
var showReshuffleNotification: Bool = false
@ -787,7 +790,7 @@ final class GameState {
// Auto-hide toasts after delay
Task {
try? await Task.sleep(for: .seconds(3))
try? await Task.sleep(for: Design.Toast.duration)
showSideBetToasts = false
}
}

View File

@ -5230,6 +5230,29 @@
}
}
},
"Reset Game" : {
"comment" : "Button to reset game balance and reshuffle cards.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Reset Game"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Reiniciar Juego"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Réinitialiser le Jeu"
}
}
}
},
"Reset to Defaults" : {
"localizations" : {
"en" : {
@ -5252,6 +5275,29 @@
}
}
},
"Restore starting balance and reshuffle" : {
"comment" : "Subtitle for Reset Game button explaining what it does.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Restore starting balance and reshuffle"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Restaurar saldo inicial y barajar"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Restaurer le solde initial et remélanger"
}
}
}
},
"Roulette" : {
"comment" : "The name of a roulette card.",
"isCommentAutoGenerated" : true,
@ -5434,52 +5480,6 @@
}
}
},
"Reset Game": {
"comment": "Button to reset game balance and reshuffle cards.",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Reset Game"
}
},
"es-MX": {
"stringUnit": {
"state": "translated",
"value": "Reiniciar Juego"
}
},
"fr-CA": {
"stringUnit": {
"state": "translated",
"value": "Réinitialiser le Jeu"
}
}
}
},
"Restore starting balance and reshuffle": {
"comment": "Subtitle for Reset Game button explaining what it does.",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Restore starting balance and reshuffle"
}
},
"es-MX": {
"stringUnit": {
"state": "translated",
"value": "Restaurar saldo inicial y barajar"
}
},
"fr-CA": {
"stringUnit": {
"state": "translated",
"value": "Restaurer le solde initial et remélanger"
}
}
}
},
"Running Count: Sum of all card values seen." : {
"localizations" : {
"en" : {
@ -5684,6 +5684,10 @@
}
}
},
"Show Hint" : {
"comment" : "Label for a toolbar button that shows a hint.",
"isCommentAutoGenerated" : true
},
"Show Hints" : {
"localizations" : {
"en" : {

View File

@ -109,6 +109,13 @@ enum Design {
/// Bounce for side bet toast animations
static let toastBounce: Double = 0.4
}
// MARK: - Toast Configuration
enum Toast {
/// Duration all toasts stay visible (in seconds).
static let duration: Duration = .seconds(2)
}
}
// MARK: - Blackjack App Colors

View File

@ -71,6 +71,31 @@ struct GameTableView: View {
// Use global debug flag from Design constants
private var showDebugBorders: Bool { Design.showDebugBorders }
// MARK: - Toolbar Buttons
/// Returns hint toolbar button when a hint is available.
private func hintToolbarButtons(for state: GameState) -> [TopBarButton] {
guard state.currentHint != nil else { return [] }
return [
TopBarButton(
icon: "lightbulb.fill",
accessibilityLabel: String(localized: "Show Hint")
) {
// Show the toast with animation
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = true
}
// Auto-dismiss after delay (same as auto-show behavior)
Task { @MainActor in
try? await Task.sleep(for: Design.Toast.duration)
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = false
}
}
}
]
}
// MARK: - Main Game View
@ViewBuilder
@ -97,6 +122,7 @@ struct GameTableView: View {
balance: state.balance,
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.cardsRemaining)" : nil,
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
leadingButtons: hintToolbarButtons(for: state),
onSettings: { showSettings = true },
onHelp: { showRules = true },
onStats: { showStats = true }
@ -143,7 +169,7 @@ struct GameTableView: View {
.padding(.bottom, Design.Spacing.small)
.debugBorder(showDebugBorders, color: .blue, label: "ActionBtns")
}
.frame(maxWidth: .infinity, alignment: .top)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.zIndex(1)
.onChange(of: state.currentPhase) { oldPhase, newPhase in
Design.debugLog("🔄 Phase changed: \(oldPhase)\(newPhase)")
@ -208,6 +234,7 @@ struct GameTableView: View {
.allowsHitTesting(true)
.zIndex(100)
}
}
.onChange(of: state.playerHands.count) { oldCount, newCount in
Design.debugLog("👥 Player hands count: \(oldCount)\(newCount)")

View File

@ -31,7 +31,6 @@ struct BlackjackTableView: View {
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium
@ScaledMetric(relativeTo: .title) private var valueFontSize: CGFloat = Design.BaseFontSize.xLarge
@ScaledMetric(relativeTo: .caption) private var hintFontSize: CGFloat = Design.BaseFontSize.small
// MARK: - Dynamic Card Sizing
@ -48,7 +47,7 @@ struct BlackjackTableView: View {
/// Card width based on full screen height (stable - doesn't change with content)
private var cardWidth: CGFloat {
let maxDimension = screenHeight
let percentage: CGFloat = 0.15 // ~10% of screen
let percentage: CGFloat = 0.18 // ~10% of screen
return maxDimension * percentage
}
@ -58,32 +57,12 @@ struct BlackjackTableView: View {
cardWidth * -0.55
}
/// Fixed height for the hint area to prevent layout shifts
private let hintAreaHeight: CGFloat = 44
// Use global debug flag from Design constants
private var showDebugBorders: Bool { Design.showDebugBorders }
/// Dynamic spacer height based on screen size.
/// Formula: spacing = clamp((screenHeight - baseline) * scale, min, max)
/// This produces smooth scaling across all device sizes:
/// - iPhone SE (~667pt): ~20pt
/// - iPhone Pro Max (~932pt): ~76pt
/// - iPad Mini (~1024pt): ~95pt
/// - iPad Pro 12.9" (~1366pt): ~150pt (capped)
private var dealerPlayerSpacing: CGFloat {
let baseline: CGFloat = 550 // Below this, use minimum
let scale: CGFloat = 0.18 // 20% of height above baseline
let minSpacing: CGFloat = 10 // Floor for smallest screens
let maxSpacing: CGFloat = 150 // Ceiling for largest screens
let calculated = (screenHeight - baseline) * scale
return min(maxSpacing, max(minSpacing, calculated))
}
var body: some View {
VStack(spacing: Design.Spacing.small) {
// Dealer area
VStack(spacing: 0) {
// Dealer area - pushed to top
DealerHandView(
hand: state.dealerHand,
showHoleCard: state.shouldShowDealerHoleCard,
@ -93,33 +72,23 @@ struct BlackjackTableView: View {
)
.debugBorder(showDebugBorders, color: .red, label: "Dealer")
// Space between dealer and player - contains card count if enabled
if showCardCount {
// Card count view fills the space between hands
VStack {
// Top spacer for larger screens
if screenHeight > 700 {
// Top spacer pushes dealer up
Spacer(minLength: Design.Spacing.small)
}
.debugBorder(showDebugBorders, color: .yellow, label: "TopSpacer")
// Card count view centered between dealer and player
if showCardCount {
CardCountView(
runningCount: state.engine.runningCount,
trueCount: state.engine.trueCount
)
// Bottom spacer for larger screens
if screenHeight > 700 {
Spacer(minLength: Design.Spacing.small)
}
}
.frame(minHeight: dealerPlayerSpacing)
.debugBorder(showDebugBorders, color: .mint, label: "CardCount")
} else {
// No card count - just use flexible spacer
Spacer(minLength: dealerPlayerSpacing)
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer \(Int(dealerPlayerSpacing))")
}
// Bottom spacer pushes player down
Spacer(minLength: Design.Spacing.small)
.debugBorder(showDebugBorders, color: .yellow, label: "BottomSpacer")
// Player hands area - only show when there are cards dealt
if state.playerHands.first?.cards.isEmpty == false {
ZStack {
@ -162,6 +131,33 @@ struct BlackjackTableView: View {
.padding(.horizontal, Design.Spacing.small)
}
}
.overlay {
// Hint toast overlaying player hands (auto-dismisses)
if state.showHintToast, let hint = state.currentHint {
HintView(hint: hint)
.transition(.scale.combined(with: .opacity))
}
}
.onChange(of: state.currentHint) { oldHint, newHint in
// Show toast when a new hint appears
if let hint = newHint, hint != oldHint {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = true
}
// Auto-dismiss after delay
Task { @MainActor in
try? await Task.sleep(for: Design.Toast.duration)
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = false
}
}
} else if newHint == nil {
// Hide immediately when no hint
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = false
}
}
}
.padding(.bottom, 5)
.transition(.opacity)
.debugBorder(showDebugBorders, color: .green, label: "Player")
@ -183,22 +179,12 @@ struct BlackjackTableView: View {
if let hint = state.bettingHint {
BettingHintView(hint: hint, trueCount: state.engine.trueCount)
.transition(.opacity)
.padding(.vertical, 10)
.debugBorder(showDebugBorders, color: .purple, label: "BetHint")
}
} else {
// Fixed-height hint area to prevent layout shifts during player turn
ZStack {
if let hint = state.currentHint {
HintView(hint: hint)
.transition(.opacity)
}
}
.frame(height: hintAreaHeight)
.debugBorder(showDebugBorders, color: .orange, label: "HintArea")
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .white, label: "TableView")
.animation(.spring(duration: Design.Animation.springDuration), value: state.currentPhase)
}

View File

@ -26,7 +26,7 @@ struct HintView: View {
.foregroundStyle(.yellow)
Text(String(localized: "Hint: \(hint)"))
.font(.system(size: fontSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
}
@ -34,8 +34,13 @@ struct HintView: View {
.padding(.vertical, paddingV)
.background(
Capsule()
.fill(Color.black.opacity(Design.Opacity.light))
.fill(Color.black.opacity(Design.Opacity.heavy))
.overlay(
Capsule()
.strokeBorder(Color.yellow.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin)
)
)
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusMedium)
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Hint"))
.accessibilityValue(hint)