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

This commit is contained in:
Matt Bruce 2025-12-26 12:23:00 -06:00
parent 645602b2de
commit 9ac5320782
7 changed files with 694 additions and 483 deletions

View File

@ -2606,6 +2606,7 @@
},
"Play Again" : {
"comment" : "A button label that says \"Play Again\".",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -2855,29 +2856,6 @@
}
}
},
"Restore starting balance and reshuffle" : {
"comment" : "Description for the 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 mélanger"
}
}
}
},
"Reset to Defaults" : {
"comment" : "A button label that resets game settings to their default values.",
"localizations" : {
@ -2901,6 +2879,29 @@
}
}
},
"Restore starting balance and reshuffle" : {
"comment" : "Description for the 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 mélanger"
}
}
}
},
"Roulette" : {
"comment" : "The name of a roulette game.",
"localizations" : {
@ -3542,6 +3543,7 @@
},
"TOTAL" : {
"comment" : "A label displayed next to the total winnings in the result banner.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -3840,6 +3842,7 @@
},
"You've run out of chips!" : {
"comment" : "A message displayed when a player runs out of money in the game over screen.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {

View File

@ -3,12 +3,14 @@
// Baccarat
//
// Animated result banner showing the winner and itemized bet results.
// Uses the shared ResultBannerView from CasinoKit.
//
import SwiftUI
import CasinoKit
/// An animated banner showing the round result with bet breakdown.
/// This is a wrapper around CasinoKit's shared ResultBannerView.
struct ResultBannerView: View {
let result: GameResult
let totalWinnings: Int
@ -20,30 +22,6 @@ struct ResultBannerView: View {
let onNewRound: () -> Void
let onGameOver: () -> Void
/// Whether the player is out of money and can't continue.
private var isGameOver: Bool {
currentBalance < minBet
}
@State private var showBanner = false
@State private var showText = false
@State private var showBreakdown = false
@State private var showTotal = false
@State private var showButton = false
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
/// Maximum width for the banner card on iPad
private var maxBannerWidth: CGFloat {
horizontalSizeClass == .regular ? CasinoDesign.Size.maxModalWidth : .infinity
}
// MARK: - Scaled Font Sizes (Dynamic Type)
@ScaledMetric(relativeTo: .largeTitle) private var resultFontSize: CGFloat = Design.BaseFontSize.largeTitle
@ScaledMetric(relativeTo: .title3) private var totalFontSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall
@ScaledMetric(relativeTo: .body) private var itemFontSize: CGFloat = Design.BaseFontSize.medium
// MARK: - Computed Properties
private var winningBets: [BetResult] {
@ -59,28 +37,13 @@ struct ResultBannerView: View {
}
var body: some View {
ZStack {
// Background overlay
Color.black.opacity(showBanner ? Design.Opacity.medium : 0)
.ignoresSafeArea()
.animation(.easeIn(duration: Design.Animation.fadeInDuration), value: showBanner)
// Banner
VStack(spacing: Design.Spacing.medium) {
// Result text
Text(result.displayText)
.font(.system(size: resultFontSize, weight: .black, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [.white, result.color],
startPoint: .top,
endPoint: .bottom
)
)
.shadow(color: result.color.opacity(Design.Opacity.heavy), radius: Design.Shadow.radiusLarge)
.scaleEffect(showText ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showText ? Design.Scale.normal : 0)
CasinoResultBannerView(
resultText: result.displayText,
resultColor: result.color,
totalWinnings: totalWinnings,
currentBalance: currentBalance,
minBet: minBet,
breakdownContent: {
// Pair indicators
if playerHadPair || bankerHadPair {
HStack(spacing: Design.Spacing.large) {
@ -91,8 +54,6 @@ struct ResultBannerView: View {
PairBadge(label: "B PAIR", color: .red)
}
}
.scaleEffect(showBreakdown ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showBreakdown ? Design.Scale.normal : 0)
}
// Bet breakdown
@ -100,204 +61,13 @@ struct ResultBannerView: View {
BetBreakdownView(
winningBets: winningBets,
losingBets: losingBets,
pushBets: pushBets,
fontSize: itemFontSize
pushBets: pushBets
)
.scaleEffect(showBreakdown ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showBreakdown ? Design.Scale.normal : 0)
}
// Total
if totalWinnings != 0 {
HStack(spacing: Design.Spacing.small) {
Text("TOTAL")
.font(.system(size: Design.BaseFontSize.body, weight: .bold))
.foregroundStyle(.white.opacity(Design.Opacity.accent))
if totalWinnings > 0 {
Text("+\(totalWinnings)")
.font(.system(size: totalFontSize, weight: .black, design: .rounded))
.foregroundStyle(.green)
} else {
Text("\(totalWinnings)")
.font(.system(size: totalFontSize, weight: .black, design: .rounded))
.foregroundStyle(.red)
}
}
.scaleEffect(showTotal ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showTotal ? Design.Scale.normal : 0)
}
// Game Over message or New Round button
if isGameOver {
// Game Over - show message and restart button
VStack(spacing: Design.Spacing.medium) {
Text(String(localized: "You've run out of chips!"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.red.opacity(Design.Opacity.heavy))
Button {
onGameOver()
} label: {
HStack(spacing: Design.Spacing.small) {
Image(systemName: "arrow.counterclockwise")
Text(String(localized: "Play Again"))
}
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.xxxLarge + Design.Spacing.small)
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
.background(
Capsule()
.fill(
LinearGradient(
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
startPoint: .top,
endPoint: .bottom
)
)
)
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium)
}
}
.scaleEffect(showButton ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showButton ? Design.Scale.normal : 0)
.padding(.top, Design.Spacing.small)
} else {
// Normal - New Round button
Button {
onNewRound()
} label: {
Text(String(localized: "New Round"))
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.xxxLarge + Design.Spacing.small)
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
.background(
Capsule()
.fill(
LinearGradient(
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
startPoint: .top,
endPoint: .bottom
)
)
)
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium)
}
.scaleEffect(showButton ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showButton ? Design.Scale.normal : 0)
.padding(.top, Design.Spacing.small)
}
}
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.xxLarge)
.frame(maxWidth: maxBannerWidth)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge)
.fill(
LinearGradient(
colors: [
Color(white: 0.15),
Color(white: 0.08)
],
startPoint: .top,
endPoint: .bottom
)
)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge)
.strokeBorder(
LinearGradient(
colors: [
result.color.opacity(Design.Opacity.heavy),
result.color.opacity(Design.Opacity.light)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: Design.LineWidth.thick
)
)
)
.shadow(color: result.color.opacity(Design.Opacity.light), radius: Design.Shadow.radiusXXLarge)
.padding(.horizontal, Design.Spacing.large)
.scaleEffect(showBanner ? Design.Scale.normal : Design.Scale.slightShrink)
.opacity(showBanner ? Design.Scale.normal : 0)
}
.onAppear {
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) {
showBanner = true
}
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay1)) {
showText = true
}
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay2)) {
showBreakdown = true
}
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay3)) {
showTotal = true
}
// Show button after everything else
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce).delay(Design.Animation.staggerDelay3 + Design.Animation.staggerDelay1)) {
showButton = true
}
// Play game over sound if out of chips (after a short delay so it doesn't overlap with lose sound)
if isGameOver {
Task {
try? await Task.sleep(for: .seconds(1))
SoundManager.shared.play(.gameOver)
}
}
// Announce result to VoiceOver users
announceResult()
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription)
}
// MARK: - Accessibility
private var accessibilityDescription: String {
var description = result.displayText
// Add pair information
if playerHadPair {
description += ". Player pair"
}
if bankerHadPair {
description += ". Banker pair"
}
// Add bet results
for bet in winningBets {
description += ". \(bet.displayName) won \(bet.payout)"
}
for bet in losingBets {
description += ". \(bet.displayName) lost \(abs(bet.payout))"
}
// Add total
if totalWinnings > 0 {
description += ". Total winnings: \(totalWinnings)"
} else if totalWinnings < 0 {
description += ". Total loss: \(abs(totalWinnings))"
}
return description
}
private func announceResult() {
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
AccessibilityNotification.Announcement(accessibilityDescription).post()
}
},
onNewRound: onNewRound,
onPlayAgain: onGameOver
)
}
}
@ -307,10 +77,11 @@ private struct BetBreakdownView: View {
let winningBets: [BetResult]
let losingBets: [BetResult]
let pushBets: [BetResult]
let fontSize: CGFloat
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = Design.BaseFontSize.medium
var body: some View {
VStack(spacing: Design.Spacing.xSmall) {
ResultBreakdownCard {
// Winning bets
ForEach(winningBets) { bet in
BetResultRow(bet: bet, fontSize: fontSize)
@ -326,12 +97,6 @@ private struct BetBreakdownView: View {
BetResultRow(bet: bet, fontSize: fontSize)
}
}
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.white.opacity(Design.Opacity.verySubtle))
)
}
}
@ -400,7 +165,7 @@ private struct PairBadge: View {
}
}
// Note: ConfettiView is now provided by CasinoKit
// MARK: - Previews
#Preview("Win") {
ZStack {

View File

@ -2625,28 +2625,6 @@
}
}
},
"Done" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Done"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Listo"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Terminé"
}
}
}
},
"Don't Ask" : {
"localizations" : {
"en" : {
@ -2669,6 +2647,28 @@
}
}
},
"Done" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Done"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Listo"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Terminé"
}
}
}
},
"Double" : {
"localizations" : {
"en" : {
@ -4480,6 +4480,7 @@
}
},
"New Round" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -4983,6 +4984,7 @@
}
},
"Play Again" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -5346,6 +5348,7 @@
},
"Round result: %@" : {
"comment" : "An accessibility label for the round result banner, describing the main hand result.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
@ -7019,6 +7022,7 @@
}
},
"You've run out of chips!" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {

View File

@ -3,11 +3,13 @@
// Blackjack
//
// Displays the result of a round with breakdown.
// Uses the shared ResultBannerView from CasinoKit.
//
import SwiftUI
import CasinoKit
/// Result banner for Blackjack using the shared CasinoKit component.
struct ResultBannerView: View {
let result: RoundResult
let currentBalance: Int
@ -15,22 +17,9 @@ struct ResultBannerView: View {
let onNewRound: () -> Void
let onPlayAgain: () -> Void
@State private var showContent = false
// MARK: - Scaled Metrics
@ScaledMetric(relativeTo: .largeTitle) private var titleFontSize: CGFloat = Design.BaseFontSize.largeTitle
@ScaledMetric(relativeTo: .title) private var resultFontSize: CGFloat = Design.BaseFontSize.title
@ScaledMetric(relativeTo: .headline) private var amountFontSize: CGFloat = Design.BaseFontSize.xLarge
@ScaledMetric(relativeTo: .body) private var buttonFontSize: CGFloat = Design.BaseFontSize.medium
// MARK: - Computed
private var isGameOver: Bool {
currentBalance < minBet
}
/// Overall result based on total winnings (what the player actually cares about)
/// Overall result text based on total winnings
private var overallResultText: String {
if result.totalWinnings > 0 {
return String(localized: "WIN!")
@ -48,60 +37,35 @@ struct ResultBannerView: View {
return .blue
}
private var winningsText: String {
if result.totalWinnings > 0 {
return "+$\(result.totalWinnings)"
} else if result.totalWinnings < 0 {
return "-$\(abs(result.totalWinnings))"
} else {
return "$0"
}
}
private var winningsColor: Color {
if result.totalWinnings > 0 { return .green }
if result.totalWinnings < 0 { return .red }
return .blue
}
var body: some View {
ZStack {
// Full screen dark background
Color.black.opacity(Design.Opacity.strong)
// Content card
VStack(spacing: Design.Spacing.xLarge) {
// Overall result based on total winnings
Text(overallResultText)
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
.foregroundStyle(overallResultColor)
// Winnings
Text(winningsText)
.font(.system(size: amountFontSize, weight: .bold, design: .rounded))
.foregroundStyle(winningsColor)
// Breakdown - all hands with amounts for splits
VStack(spacing: Design.Spacing.small) {
CasinoResultBannerView(
resultText: overallResultText,
resultColor: overallResultColor,
totalWinnings: result.totalWinnings,
currentBalance: currentBalance,
minBet: minBet,
breakdownContent: {
ResultBreakdownCard {
// Hand results
ForEach(result.handResults.indices, id: \.self) { index in
let handResult = result.handResults[index]
let handWinnings = index < result.handWinnings.count ? result.handWinnings[index] : nil
// Hand numbering: index 0 = Hand 1 (played first, displayed rightmost)
let handLabel = result.handResults.count > 1
? String(localized: "Hand \(index + 1)")
: String(localized: "Main Hand")
// Show amounts for split hands, or for single hand if there are winnings
let showAmount = result.hadSplit && handWinnings != nil
ResultRow(
HandResultRow(
label: handLabel,
result: handResult,
amount: showAmount ? handWinnings : nil
)
}
// Insurance result
if let insuranceResult = result.insuranceResult {
let showInsAmount = result.insuranceWinnings != 0
ResultRow(
HandResultRow(
label: String(localized: "Insurance"),
result: insuranceResult,
amount: showInsAmount ? result.insuranceWinnings : nil
@ -127,124 +91,44 @@ struct ResultBannerView: View {
)
}
}
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.white.opacity(Design.Opacity.subtle))
)
// Game over message
if isGameOver {
VStack(spacing: Design.Spacing.small) {
Text(String(localized: "You've run out of chips!"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
Button(action: onPlayAgain) {
HStack(spacing: Design.Spacing.small) {
Image(systemName: "arrow.counterclockwise")
Text(String(localized: "Play Again"))
}
.font(.system(size: buttonFontSize, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.vertical, Design.Spacing.medium)
.background(
Capsule()
.fill(
LinearGradient(
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
startPoint: .top,
endPoint: .bottom
)
)
)
}
}
} else {
// New Round button
Button(action: onNewRound) {
HStack(spacing: Design.Spacing.small) {
Image(systemName: "arrow.clockwise")
Text(String(localized: "New Round"))
}
.font(.system(size: buttonFontSize, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.vertical, Design.Spacing.medium)
.background(
Capsule()
.fill(
LinearGradient(
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
startPoint: .top,
endPoint: .bottom
)
)
)
}
}
}
.padding(Design.Spacing.xxLarge)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge)
.fill(
LinearGradient(
colors: [Color.CasinoModal.backgroundLight, Color.CasinoModal.backgroundDark],
startPoint: .top,
endPoint: .bottom
)
)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge)
.strokeBorder(
overallResultColor.opacity(Design.Opacity.medium),
lineWidth: Design.LineWidth.medium
)
)
)
.shadow(color: overallResultColor.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXLarge)
.frame(maxWidth: CasinoDesign.Size.maxModalWidth)
.padding(.horizontal, Design.Spacing.large) // Prevent clipping on sides
.scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink)
.opacity(showContent ? 1.0 : 0)
}
.onAppear {
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) {
showContent = true
}
// Play game over sound if out of chips (after a delay so it doesn't overlap with result sound)
if isGameOver {
Task {
try? await Task.sleep(for: .seconds(Design.Delay.gameOverSound))
SoundManager.shared.play(.gameOver)
}
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Round result: \(result.mainHandResult.displayText)"))
.accessibilityAddTraits(AccessibilityTraits.isModal)
},
onNewRound: onNewRound,
onPlayAgain: onPlayAgain
)
}
}
// MARK: - Result Row
// MARK: - Hand Result Row
struct ResultRow: View {
/// Row displaying a single hand result with optional amount.
private struct HandResultRow: View {
let label: String
let result: HandResult
var amount: Int? = nil
private var showDebugBorders: Bool { Design.showDebugBorders }
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = Design.BaseFontSize.medium
private var statusIcon: String {
switch result {
case .blackjack, .win, .insuranceWin:
return "checkmark.circle.fill"
case .lose, .bust, .insuranceLose:
return "xmark.circle.fill"
case .push:
return "arrow.left.arrow.right.circle.fill"
case .surrender:
return "flag.circle.fill"
}
}
private var amountText: String? {
guard let amount = amount else { return nil }
if amount > 0 {
return "+$\(amount)"
return "+\(amount)"
} else if amount < 0 {
return "-$\(abs(amount))"
return "\(amount)"
} else {
return "$0"
return nil
}
}
@ -256,51 +140,56 @@ struct ResultRow: View {
}
var body: some View {
HStack {
HStack(spacing: Design.Spacing.small) {
// Status icon
Image(systemName: statusIcon)
.font(.system(size: fontSize))
.foregroundStyle(result.color)
// Label
Text(label)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
.debugBorder(showDebugBorders, color: .blue, label: "Label")
.font(.system(size: fontSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.heavy))
Spacer()
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer")
// Show amount if provided
// Amount (if provided)
if let amountText = amountText {
Text(amountText)
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded))
.font(.system(size: fontSize, weight: .semibold, design: .rounded))
.foregroundStyle(amountColor)
.frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing)
.debugBorder(showDebugBorders, color: .green, label: "Amount")
}
// Result text
Text(result.displayText)
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
.font(.system(size: fontSize + 2, weight: .bold))
.foregroundStyle(result.color)
.frame(width: Design.Size.resultRowResultWidth, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
.debugBorder(showDebugBorders, color: .red, label: "Result")
}
.debugBorder(showDebugBorders, color: .white, label: "ResultRow")
}
}
// MARK: - Side Bet Result Row
struct SideBetResultRow: View {
/// Row displaying a side bet result.
private struct SideBetResultRow: View {
let label: String
let resultText: String
let isWin: Bool
let amount: Int
private var showDebugBorders: Bool { Design.showDebugBorders }
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = Design.BaseFontSize.medium
private var statusIcon: String {
isWin ? "checkmark.circle.fill" : "xmark.circle.fill"
}
private var amountText: String {
if amount > 0 {
return "+$\(amount)"
return "+\(amount)"
} else if amount < 0 {
return "-$\(abs(amount))"
return "\(amount)"
} else {
return "$0"
}
@ -317,33 +206,36 @@ struct SideBetResultRow: View {
}
var body: some View {
HStack {
HStack(spacing: Design.Spacing.small) {
// Status icon
Image(systemName: statusIcon)
.font(.system(size: fontSize))
.foregroundStyle(resultColor)
// Label
Text(label)
.font(.system(size: Design.BaseFontSize.medium))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
.debugBorder(showDebugBorders, color: .blue, label: "Label")
.font(.system(size: fontSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.heavy))
Spacer()
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer")
// Amount
Text(amountText)
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold, design: .rounded))
.font(.system(size: fontSize, weight: .semibold, design: .rounded))
.foregroundStyle(amountColor)
.frame(width: Design.Size.resultRowAmountWidth, alignment: .trailing)
.debugBorder(showDebugBorders, color: .green, label: "Amount")
// Result text
Text(resultText)
.font(.system(size: Design.BaseFontSize.large, weight: .bold))
.font(.system(size: fontSize + 2, weight: .bold))
.foregroundStyle(resultColor)
.frame(width: Design.Size.resultRowResultWidth, alignment: .trailing)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
.debugBorder(showDebugBorders, color: .red, label: "Result")
}
.debugBorder(showDebugBorders, color: .white, label: "SideBetRow")
}
}
// MARK: - Previews
#Preview("Single Hand") {
ResultBannerView(
result: RoundResult(
@ -416,3 +308,19 @@ struct SideBetResultRow: View {
)
}
#Preview("Game Over") {
ResultBannerView(
result: RoundResult(
handResults: [.bust],
handWinnings: [-100],
insuranceResult: nil,
insuranceWinnings: 0,
totalWinnings: -100,
wasBlackjack: false
),
currentBalance: 0,
minBet: 10,
onNewRound: {},
onPlayAgain: {}
)
}

View File

@ -27,6 +27,9 @@
// MARK: - Overlays
// - GameOverView
// - CasinoResultBannerView (animated result banner with staggered animations)
// - ResultBreakdownCard (styled container for bet breakdown)
// - ResultItemRow (generic result row with icon, label, amount)
// MARK: - Table
// - TableBackgroundView, FeltPatternView

View File

@ -165,6 +165,10 @@
}
}
},
"+%lld" : {
"comment" : "A label displaying the total winnings amount in the result banner. The argument is the total winnings amount.",
"isCommentAutoGenerated" : true
},
"$" : {
"comment" : "The dollar sign used in the top bar.",
"isCommentAutoGenerated" : true,
@ -916,6 +920,10 @@
}
}
},
"Game over. You've run out of chips." : {
"comment" : "Accessibility label for the result banner when the game is over and the user has run out of chips.",
"isCommentAutoGenerated" : true
},
"Game progress and statistics are stored locally on your device" : {
"localizations" : {
"en" : {
@ -1334,6 +1342,10 @@
}
}
},
"New Round" : {
"comment" : "A button label that initiates a new round of a casino game.",
"isCommentAutoGenerated" : true
},
"Nine" : {
"localizations" : {
"en" : {
@ -1890,6 +1902,18 @@
}
}
},
"TOTAL" : {
"comment" : "A label displayed alongside the total winnings in the result banner.",
"isCommentAutoGenerated" : true
},
"Total loss: %lld" : {
"comment" : "A string that describes the total loss in a casino game. The argument is the absolute value of the total loss, formatted in U.S. Dollars.",
"isCommentAutoGenerated" : true
},
"Total winnings: %lld" : {
"comment" : "A description of the result text that includes the total winnings. The argument is the total winnings, formatted as currency.",
"isCommentAutoGenerated" : true
},
"Two" : {
"localizations" : {
"en" : {

View File

@ -0,0 +1,504 @@
//
// ResultBannerView.swift
// CasinoKit
//
// A reusable animated result banner for casino games.
// Each game provides its own breakdown content via a @ViewBuilder closure.
//
import SwiftUI
/// A reusable result banner with staggered animations.
///
/// This component provides the shared structure for result displays:
/// - Animated dark overlay
/// - Result text with gradient and glow
/// - Game-specific breakdown content (provided via @ViewBuilder)
/// - Total winnings display
/// - New Round / Play Again button
/// - Game over handling
///
/// Usage:
/// ```swift
/// CasinoResultBannerView(
/// resultText: "WIN!",
/// resultColor: .green,
/// totalWinnings: 500,
/// currentBalance: 10500,
/// minBet: 10,
/// breakdownContent: {
/// // Your game-specific breakdown views here
/// ForEach(betResults) { bet in
/// BetResultRow(bet: bet)
/// }
/// },
/// onNewRound: { startNewRound() },
/// onPlayAgain: { resetGame() }
/// )
/// ```
public struct CasinoResultBannerView<Content: View>: View {
// MARK: - Parameters
/// The result text to display (e.g., "WIN!", "PLAYER WINS", "BLACKJACK!")
public let resultText: String
/// The color associated with the result (green for win, red for loss, etc.)
public let resultColor: Color
/// Total winnings/losses for this round
public let totalWinnings: Int
/// Current balance to determine if game over
public let currentBalance: Int
/// Minimum bet to determine if player can continue
public let minBet: Int
/// Game-specific breakdown content (bet results, hand results, etc.)
@ViewBuilder public let breakdownContent: Content
/// Action when "New Round" is tapped
public let onNewRound: () -> Void
/// Action when "Play Again" is tapped (game over state)
public let onPlayAgain: () -> Void
// MARK: - Animation States
@State private var showBanner = false
@State private var showText = false
@State private var showBreakdown = false
@State private var showTotal = false
@State private var showButton = false
// MARK: - Environment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
// MARK: - Scaled Font Sizes (Dynamic Type)
@ScaledMetric(relativeTo: .largeTitle) private var resultFontSize: CGFloat = CasinoDesign.BaseFontSize.largeTitle
@ScaledMetric(relativeTo: .title3) private var totalFontSize: CGFloat = CasinoDesign.BaseFontSize.xxLarge + CasinoDesign.Spacing.xSmall
@ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = CasinoDesign.BaseFontSize.xLarge
// MARK: - Computed Properties
/// Whether the player is out of money and can't continue.
private var isGameOver: Bool {
currentBalance < minBet
}
/// Maximum width for the banner card on iPad
private var maxBannerWidth: CGFloat {
horizontalSizeClass == .regular ? CasinoDesign.Size.maxModalWidth : .infinity
}
// MARK: - Initializer
/// Creates a result banner view.
/// - Parameters:
/// - resultText: The main result text to display.
/// - resultColor: The color for the result (affects text gradient, border, shadow).
/// - totalWinnings: The total amount won or lost this round.
/// - currentBalance: The player's current balance (for game over detection).
/// - minBet: The minimum bet amount (for game over detection).
/// - breakdownContent: Game-specific content showing bet/hand breakdown.
/// - onNewRound: Action when "New Round" button is tapped.
/// - onPlayAgain: Action when "Play Again" button is tapped (game over state).
public init(
resultText: String,
resultColor: Color,
totalWinnings: Int,
currentBalance: Int,
minBet: Int,
@ViewBuilder breakdownContent: () -> Content,
onNewRound: @escaping () -> Void,
onPlayAgain: @escaping () -> Void
) {
self.resultText = resultText
self.resultColor = resultColor
self.totalWinnings = totalWinnings
self.currentBalance = currentBalance
self.minBet = minBet
self.breakdownContent = breakdownContent()
self.onNewRound = onNewRound
self.onPlayAgain = onPlayAgain
}
// MARK: - Body
public var body: some View {
ZStack {
// Background overlay
Color.black.opacity(showBanner ? CasinoDesign.Opacity.medium : 0)
.ignoresSafeArea()
.animation(.easeIn(duration: CasinoDesign.Animation.fadeInDuration), value: showBanner)
// Banner card
VStack(spacing: CasinoDesign.Spacing.medium) {
// Result text with gradient and glow
Text(resultText)
.font(.system(size: resultFontSize, weight: .black, design: .rounded))
.foregroundStyle(
LinearGradient(
colors: [.white, resultColor],
startPoint: .top,
endPoint: .bottom
)
)
.shadow(color: resultColor.opacity(CasinoDesign.Opacity.heavy), radius: CasinoDesign.Shadow.radiusLarge)
.scaleEffect(showText ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
.opacity(showText ? CasinoDesign.Scale.normal : 0)
// Game-specific breakdown content
breakdownContent
.scaleEffect(showBreakdown ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
.opacity(showBreakdown ? CasinoDesign.Scale.normal : 0)
// Total winnings
if totalWinnings != 0 {
totalWinningsView
.scaleEffect(showTotal ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
.opacity(showTotal ? CasinoDesign.Scale.normal : 0)
}
// Button section
buttonSection
.scaleEffect(showButton ? CasinoDesign.Scale.normal : CasinoDesign.Scale.shrunk)
.opacity(showButton ? CasinoDesign.Scale.normal : 0)
.padding(.top, CasinoDesign.Spacing.small)
}
.padding(.horizontal, CasinoDesign.Spacing.xLarge)
.padding(.vertical, CasinoDesign.Spacing.xxLarge)
.frame(maxWidth: maxBannerWidth)
.background(bannerBackground)
.shadow(color: resultColor.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusXXLarge)
.padding(.horizontal, CasinoDesign.Spacing.large)
.scaleEffect(showBanner ? CasinoDesign.Scale.normal : CasinoDesign.Scale.slightShrink)
.opacity(showBanner ? CasinoDesign.Scale.normal : 0)
}
.onAppear {
startAnimations()
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription)
}
// MARK: - Subviews
private var totalWinningsView: some View {
HStack(spacing: CasinoDesign.Spacing.small) {
Text(String(localized: "TOTAL", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.body, weight: .bold))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.accent))
if totalWinnings > 0 {
Text("+\(totalWinnings)")
.font(.system(size: totalFontSize, weight: .black, design: .rounded))
.foregroundStyle(.green)
} else {
Text("\(totalWinnings)")
.font(.system(size: totalFontSize, weight: .black, design: .rounded))
.foregroundStyle(.red)
}
}
}
@ViewBuilder
private var buttonSection: some View {
if isGameOver {
// Game Over - show message and restart button
VStack(spacing: CasinoDesign.Spacing.medium) {
Text(String(localized: "You've run out of chips!", bundle: .module))
.font(.system(size: CasinoDesign.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.red.opacity(CasinoDesign.Opacity.heavy))
Button {
onPlayAgain()
} label: {
HStack(spacing: CasinoDesign.Spacing.small) {
Image(systemName: "arrow.counterclockwise")
Text(String(localized: "Play Again", bundle: .module))
}
.font(.system(size: buttonFontSize, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, CasinoDesign.Spacing.xxxLarge + CasinoDesign.Spacing.small)
.padding(.vertical, CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall)
.background(goldButtonBackground)
}
}
} else {
// Normal - New Round button
Button {
onNewRound()
} label: {
Text(String(localized: "New Round", bundle: .module))
.font(.system(size: buttonFontSize, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, CasinoDesign.Spacing.xxxLarge + CasinoDesign.Spacing.small)
.padding(.vertical, CasinoDesign.Spacing.medium + CasinoDesign.Spacing.xxSmall)
.background(goldButtonBackground)
}
}
}
private var goldButtonBackground: some View {
Capsule()
.fill(
LinearGradient(
colors: [Color.CasinoButton.goldLight, Color.CasinoButton.goldDark],
startPoint: .top,
endPoint: .bottom
)
)
.shadow(color: .yellow.opacity(CasinoDesign.Opacity.light), radius: CasinoDesign.Shadow.radiusMedium)
}
private var bannerBackground: some View {
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.xxLarge)
.fill(
LinearGradient(
colors: [
Color.CasinoModal.backgroundLight,
Color.CasinoModal.backgroundDark
],
startPoint: .top,
endPoint: .bottom
)
)
.overlay(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.xxLarge)
.strokeBorder(
LinearGradient(
colors: [
resultColor.opacity(CasinoDesign.Opacity.heavy),
resultColor.opacity(CasinoDesign.Opacity.light)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: CasinoDesign.LineWidth.thick
)
)
}
// MARK: - Animations
private func startAnimations() {
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce)) {
showBanner = true
}
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay1)) {
showText = true
}
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay2)) {
showBreakdown = true
}
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay3)) {
showTotal = true
}
// Show button after everything else
withAnimation(.spring(duration: CasinoDesign.Animation.springDuration, bounce: CasinoDesign.Animation.springBounce).delay(CasinoDesign.Animation.staggerDelay3 + CasinoDesign.Animation.staggerDelay1)) {
showButton = true
}
// Play game over sound if out of chips
if isGameOver {
Task {
try? await Task.sleep(for: .seconds(1))
SoundManager.shared.play(.gameOver)
}
}
// Announce result to VoiceOver users
announceResult()
}
// MARK: - Accessibility
private var accessibilityDescription: String {
var description = resultText
if totalWinnings > 0 {
description += ". " + String(localized: "Total winnings: \(totalWinnings)", bundle: .module)
} else if totalWinnings < 0 {
description += ". " + String(localized: "Total loss: \(abs(totalWinnings))", bundle: .module)
}
if isGameOver {
description += ". " + String(localized: "Game over. You've run out of chips.", bundle: .module)
}
return description
}
private func announceResult() {
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
AccessibilityNotification.Announcement(accessibilityDescription).post()
}
}
}
// MARK: - Result Breakdown Card
/// A styled container for bet/hand breakdown content.
/// Provides consistent styling across all casino games.
public struct ResultBreakdownCard<Content: View>: View {
@ViewBuilder public let content: Content
public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
VStack(spacing: CasinoDesign.Spacing.xSmall) {
content
}
.padding(.horizontal, CasinoDesign.Spacing.medium)
.padding(.vertical, CasinoDesign.Spacing.small)
.background(
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium)
.fill(Color.white.opacity(CasinoDesign.Opacity.verySubtle))
)
}
}
// MARK: - Result Row
/// A generic row for displaying a result item with label, optional amount, and status.
public struct ResultItemRow: View {
public let label: String
public let statusText: String
public let statusColor: Color
public var amount: Int?
public var statusIcon: String?
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = CasinoDesign.BaseFontSize.medium
public init(
label: String,
statusText: String,
statusColor: Color,
amount: Int? = nil,
statusIcon: String? = nil
) {
self.label = label
self.statusText = statusText
self.statusColor = statusColor
self.amount = amount
self.statusIcon = statusIcon
}
private var amountText: String? {
guard let amount = amount else { return nil }
if amount > 0 {
return "+\(amount)"
} else if amount < 0 {
return "\(amount)"
} else {
return nil
}
}
private var amountColor: Color {
guard let amount = amount else { return .white }
if amount > 0 { return .green }
if amount < 0 { return .red }
return .blue
}
public var body: some View {
HStack(spacing: CasinoDesign.Spacing.small) {
// Status icon (if provided)
if let icon = statusIcon {
Image(systemName: icon)
.font(.system(size: fontSize))
.foregroundStyle(statusColor)
}
// Label
Text(label)
.font(.system(size: fontSize, weight: .medium))
.foregroundStyle(.white.opacity(CasinoDesign.Opacity.heavy))
Spacer()
// Amount (if provided)
if let amountText = amountText {
Text(amountText)
.font(.system(size: fontSize, weight: .semibold, design: .rounded))
.foregroundStyle(amountColor)
}
// Status text
Text(statusText)
.font(.system(size: fontSize + 2, weight: .bold))
.foregroundStyle(statusColor)
}
}
}
// MARK: - Preview
#Preview("Win Result") {
ZStack {
Color.CasinoTable.felt
.ignoresSafeArea()
CasinoResultBannerView(
resultText: "WIN!",
resultColor: .green,
totalWinnings: 500,
currentBalance: 10500,
minBet: 10,
breakdownContent: {
ResultBreakdownCard {
ResultItemRow(
label: "Main Hand",
statusText: "WIN",
statusColor: .green,
amount: 500,
statusIcon: "checkmark.circle.fill"
)
}
},
onNewRound: {},
onPlayAgain: {}
)
}
}
#Preview("Game Over") {
ZStack {
Color.CasinoTable.felt
.ignoresSafeArea()
CasinoResultBannerView(
resultText: "LOSE",
resultColor: .red,
totalWinnings: -100,
currentBalance: 0,
minBet: 10,
breakdownContent: {
ResultBreakdownCard {
ResultItemRow(
label: "Main Hand",
statusText: "BUST",
statusColor: .red,
amount: -100,
statusIcon: "xmark.circle.fill"
)
}
},
onNewRound: {},
onPlayAgain: {}
)
}
}