CasinoGames/Baccarat/Views/ResultBannerView.swift

504 lines
19 KiB
Swift

//
// ResultBannerView.swift
// Baccarat
//
// Animated result banner showing the winner and itemized bet results.
//
import SwiftUI
import CasinoKit
/// An animated banner showing the round result with bet breakdown.
struct ResultBannerView: View {
let result: GameResult
let totalWinnings: Int
let betResults: [BetResult]
var playerHadPair: Bool = false
var bankerHadPair: Bool = false
let currentBalance: Int
let minBet: Int
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 ? Design.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] {
betResults.filter { $0.isWin }
}
private var losingBets: [BetResult] {
betResults.filter { $0.isLoss }
}
private var pushBets: [BetResult] {
betResults.filter { $0.isPush }
}
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)
// Pair indicators
if playerHadPair || bankerHadPair {
HStack(spacing: Design.Spacing.large) {
if playerHadPair {
PairBadge(label: "P PAIR", color: .blue)
}
if bankerHadPair {
PairBadge(label: "B PAIR", color: .red)
}
}
.scaleEffect(showBreakdown ? Design.Scale.normal : Design.Scale.shrunk)
.opacity(showBreakdown ? Design.Scale.normal : 0)
}
// Bet breakdown
if !betResults.isEmpty {
BetBreakdownView(
winningBets: winningBets,
losingBets: losingBets,
pushBets: pushBets,
fontSize: itemFontSize
)
.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.Button.goldLight, Color.Button.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.Button.goldLight, Color.Button.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)
.accessibilityAddTraits(.updatesFrequently)
}
// 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()
}
}
}
// MARK: - Bet Breakdown View
private struct BetBreakdownView: View {
let winningBets: [BetResult]
let losingBets: [BetResult]
let pushBets: [BetResult]
let fontSize: CGFloat
var body: some View {
VStack(spacing: Design.Spacing.xSmall) {
// Winning bets
ForEach(winningBets) { bet in
BetResultRow(bet: bet, fontSize: fontSize)
}
// Push bets
ForEach(pushBets) { bet in
BetResultRow(bet: bet, fontSize: fontSize)
}
// Losing bets
ForEach(losingBets) { bet in
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))
)
}
}
// MARK: - Bet Result Row
private struct BetResultRow: View {
let bet: BetResult
let fontSize: CGFloat
private var statusColor: Color {
if bet.isWin { return .green }
if bet.isLoss { return .red }
return .yellow // Push
}
private var statusIcon: String {
if bet.isWin { return "checkmark.circle.fill" }
if bet.isLoss { return "xmark.circle.fill" }
return "arrow.left.arrow.right.circle.fill" // Push
}
private var payoutText: String {
if bet.isWin { return "+\(bet.payout)" }
if bet.isLoss { return "\(bet.payout)" }
return "PUSH"
}
var body: some View {
HStack(spacing: Design.Spacing.small) {
// Status icon
Image(systemName: statusIcon)
.font(.system(size: fontSize))
.foregroundStyle(statusColor)
// Bet name
Text(bet.displayName)
.font(.system(size: fontSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.heavy))
Spacer()
// Payout
Text(payoutText)
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundStyle(statusColor)
}
}
}
// MARK: - Pair Badge
private struct PairBadge: View {
let label: String
let color: Color
var body: some View {
Text(label)
.font(.system(size: Design.BaseFontSize.callout - Design.Spacing.xxSmall, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xxSmall)
.background(
Capsule()
.fill(color)
)
}
}
/// Confetti particle for celebrations.
struct ConfettiPiece: View {
let color: Color
let containerSize: CGSize
@State private var position: CGPoint = .zero
@State private var rotation: Double = 0
@State private var opacity: Double = 1
private let confettiWidth: CGFloat = 8
private let confettiHeight: CGFloat = 12
var body: some View {
Rectangle()
.fill(color)
.frame(width: confettiWidth, height: confettiHeight)
.rotationEffect(.degrees(rotation))
.position(position)
.opacity(opacity)
.onAppear {
let startX = Double.random(in: 0...containerSize.width)
position = CGPoint(x: startX, y: -20)
withAnimation(.easeIn(duration: Double.random(in: 2...4))) {
position = CGPoint(
x: startX + Double.random(in: -100...100),
y: containerSize.height + 50
)
rotation = Double.random(in: 360...1080)
opacity = 0
}
}
}
}
/// A confetti celebration overlay.
struct ConfettiView: View {
let colors: [Color] = [.red, .blue, .green, .yellow, .orange, .purple, .pink]
var body: some View {
GeometryReader { geometry in
ZStack {
ForEach(0..<50, id: \.self) { _ in
ConfettiPiece(
color: colors.randomElement() ?? .yellow,
containerSize: geometry.size
)
}
}
}
.ignoresSafeArea()
.allowsHitTesting(false)
.accessibilityHidden(true)
}
}
#Preview("Win") {
ZStack {
Color.Table.preview
.ignoresSafeArea()
ResultBannerView(
result: .playerWins,
totalWinnings: 1500,
betResults: [
BetResult(type: .player, amount: 1000, payout: 1000),
BetResult(type: .playerPair, amount: 100, payout: 1100),
BetResult(type: .dragonBonusPlayer, amount: 100, payout: 200),
BetResult(type: .tie, amount: 500, payout: -500)
],
playerHadPair: true,
bankerHadPair: false,
currentBalance: 5000,
minBet: 10,
onNewRound: {},
onGameOver: {}
)
}
}
#Preview("Game Over") {
ZStack {
Color.Table.preview
.ignoresSafeArea()
ResultBannerView(
result: .bankerWins,
totalWinnings: -1000,
betResults: [
BetResult(type: .player, amount: 1000, payout: -1000)
],
playerHadPair: false,
bankerHadPair: false,
currentBalance: 0,
minBet: 10,
onNewRound: {},
onGameOver: {}
)
}
}