488 lines
18 KiB
Swift
488 lines
18 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
|
|
|
|
// 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)
|
|
.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)
|
|
.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
|
|
@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 screenWidth = 400.0
|
|
let startX = Double.random(in: 0...screenWidth)
|
|
position = CGPoint(x: startX, y: -20)
|
|
|
|
withAnimation(.easeIn(duration: Double.random(in: 2...4))) {
|
|
position = CGPoint(
|
|
x: startX + Double.random(in: -100...100),
|
|
y: 800
|
|
)
|
|
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 {
|
|
ZStack {
|
|
ForEach(0..<50, id: \.self) { _ in
|
|
ConfettiPiece(color: colors.randomElement() ?? .yellow)
|
|
}
|
|
}
|
|
.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: {}
|
|
)
|
|
}
|
|
}
|