Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
645602b2de
commit
9ac5320782
@ -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" : {
|
||||
|
||||
@ -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
|
||||
},
|
||||
onNewRound: onNewRound,
|
||||
onPlayAgain: onGameOver
|
||||
)
|
||||
)
|
||||
)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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))
|
||||
},
|
||||
onNewRound: onNewRound,
|
||||
onPlayAgain: onPlayAgain
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,32 +206,35 @@ 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(
|
||||
@ -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: {}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" : {
|
||||
|
||||
@ -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: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user