CasinoGames/Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift
Matt Bruce 88d8c26865 added in style card reveal styles
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2026-01-21 15:17:05 -06:00

412 lines
15 KiB
Swift

//
// CardsDisplayArea.swift
// Baccarat
//
// The cards display area showing both Player and Banker hands.
// Animates from side-by-side (betting) to vertical stack (dealing).
//
import SwiftUI
import CasinoKit
/// The cards display area showing both hands.
/// - Betting phase: Horizontal side-by-side layout (Player | Banker)
/// - Dealing phase: Vertical stack with betted hand on bottom
struct CardsDisplayArea: View {
let playerCards: [Card]
let bankerCards: [Card]
let playerCardsFaceUp: [Bool]
let bankerCardsFaceUp: [Bool]
let playerValue: Int
let bankerValue: Int
let playerIsWinner: Bool
let bankerIsWinner: Bool
let isTie: Bool
let showAnimations: Bool
let dealingSpeed: Double
let revealStyle: RevealStyle
let isWaitingForReveal: Bool
let currentRevealIndex: Int
let revealProgress: Double
/// Which main bet is placed - nil if no main bet, true if Player, false if Banker.
let bettedOnPlayer: Bool?
/// Whether the game is in dealing/result phase (vertical layout) or betting phase (horizontal).
let isDealing: Bool
/// Full screen size for calculating card width in dealing mode.
let screenSize: CGSize
let onReveal: () -> Void
let onUpdateProgress: (Double) -> Void
// MARK: - State
@State private var containerWidth: CGFloat = 300
@Namespace private var animation
// MARK: - Environment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
// MARK: - Computed Properties
private var isLargeScreen: Bool {
horizontalSizeClass == .regular
}
/// Whether we're in landscape mode
private var isLandscape: Bool {
verticalSizeClass == .compact || (isLargeScreen && screenSize.width > screenSize.height)
}
private var showDebugBorders: Bool { Design.showDebugBorders }
private var labelFontSize: CGFloat {
isLargeScreen ? 18 : Design.Size.labelFontSize
}
private var labelRowMinHeight: CGFloat {
isLargeScreen ? 40 : Design.Size.labelRowHeight
}
/// Whether Player hand should be on bottom in vertical mode.
private var playerOnBottom: Bool {
bettedOnPlayer ?? true
}
/// Spacing between hands
private var handsSpacing: CGFloat {
if isDealing {
return isLargeScreen ? Design.Spacing.large : Design.Spacing.medium
} else {
return isLargeScreen ? Design.Spacing.xxxLarge : Design.Spacing.large
}
}
// MARK: - Card Width Calculations
/// Card section width for horizontal (betting) mode
private var horizontalHandWidth: CGFloat {
let spacing = isLargeScreen ? Design.Spacing.xxxLarge : Design.Spacing.large
return max(100, (containerWidth - spacing) / 2)
}
/// Card section width for vertical (dealing) mode - matches Blackjack sizing
/// Uses screen height as base (like Blackjack), which is smaller in landscape
private var verticalHandWidth: CGFloat {
// Use screen height (smaller dimension in landscape = smaller cards)
let height = screenSize.height
guard height > 100 else { return horizontalHandWidth }
// Blackjack uses 0.18, but in landscape we may need slightly smaller
let percentage: CGFloat = isLandscape ? 0.175 : height < 700 ? 0.14 : 0.18
let cardWidth = height * percentage
// CompactHandView: cardWidth = containerWidth / divisor
let overlapRatio: CGFloat = -0.45
let maxCards: CGFloat = 3
let divisor = 1 + (maxCards - 1) * (1 + overlapRatio)
return cardWidth * divisor
}
/// Current hand section width based on mode
private var handSectionWidth: CGFloat {
isDealing ? verticalHandWidth : horizontalHandWidth
}
// MARK: - Accessibility
private var playerHandDescription: String {
if playerCards.isEmpty {
return String(localized: "No cards")
}
let visibleCards = zip(playerCards, playerCardsFaceUp)
.filter { $1 }
.map { "\($0.0.rank.accessibilityName) of \($0.0.suit.accessibilityName)" }
if visibleCards.isEmpty {
return String(localized: "Cards face down")
}
let format = String(localized: "handValueFormat")
return visibleCards.joined(separator: ", ") + ". " + String(format: format, playerValue)
}
private var bankerHandDescription: String {
if bankerCards.isEmpty {
return String(localized: "No cards")
}
let visibleCards = zip(bankerCards, bankerCardsFaceUp)
.filter { $1 }
.map { "\($0.0.rank.accessibilityName) of \($0.0.suit.accessibilityName)" }
if visibleCards.isEmpty {
return String(localized: "Cards face down")
}
let format = String(localized: "handValueFormat")
return visibleCards.joined(separator: ", ") + ". " + String(format: format, bankerValue)
}
// MARK: - Body
var body: some View {
// Use different layouts but keep view identity with matchedGeometryEffect
Group {
if isDealing {
// Vertical layout with flexible spacing between hands
VStack(spacing: 0) {
if playerOnBottom {
bankerHandSection(width: handSectionWidth)
.matchedGeometryEffect(id: "banker", in: animation)
// Flexible spacer expands on taller screens
Spacer(minLength: handsSpacing)
playerHandSection(width: handSectionWidth)
.matchedGeometryEffect(id: "player", in: animation)
} else {
playerHandSection(width: handSectionWidth)
.matchedGeometryEffect(id: "player", in: animation)
// Flexible spacer expands on taller screens
Spacer(minLength: handsSpacing)
bankerHandSection(width: handSectionWidth)
.matchedGeometryEffect(id: "banker", in: animation)
}
}
} else {
// Horizontal layout - Player left, Banker right
HStack(spacing: handsSpacing) {
playerHandSection(width: handSectionWidth)
.matchedGeometryEffect(id: "player", in: animation)
bankerHandSection(width: handSectionWidth)
.matchedGeometryEffect(id: "banker", in: animation)
}
}
}
.frame(maxWidth: .infinity)
.padding(.top, Design.Spacing.medium)
.padding(.bottom, Design.Spacing.large)
.background(
GeometryReader { geometry in
Color.clear
.onAppear {
containerWidth = geometry.size.width * 0.95
}
.onChange(of: geometry.size.width) { _, newWidth in
containerWidth = newWidth * 0.95
}
}
)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge)
.fill(Color.black.opacity(isWaitingForReveal ? 0.05 : Design.Opacity.quarter))
.accessibilityHidden(true)
)
.debugBorder(showDebugBorders, color: .mint, label: "HandsContainer")
.animation(.spring(duration: 0.6, bounce: 0.2), value: isDealing)
.animation(.spring(duration: 0.5, bounce: 0.15), value: playerOnBottom)
}
// MARK: - Private Views
private func playerHandSection(width: CGFloat) -> some View {
// Calculate value from face-up cards
let visibleValue: Int = {
guard !playerCards.isEmpty else { return 0 }
let faceUpCards = zip(playerCards, playerCardsFaceUp)
.filter { $1 }
.map { $0.0 }
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
}()
let allCardsFaceUp = !playerCards.isEmpty && playerCardsFaceUp.count == playerCards.count && playerCardsFaceUp.allSatisfy({ $0 })
let _ = CasinoDesign.debugLog("🎯 Player: cards=\(playerCards.count), faceUp=\(playerCardsFaceUp.count), allFaceUp=\(allCardsFaceUp), visibleValue=\(visibleValue)")
return VStack(spacing: Design.Spacing.small) {
HStack(spacing: Design.Spacing.small) {
Text("PLAYER")
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(.white)
// Always show value when there are cards - it updates as cards become visible
if !playerCards.isEmpty {
HandValueBadge(value: visibleValue, color: .blue)
.animation(nil, value: visibleValue) // No animation when value changes
}
}
.frame(minHeight: labelRowMinHeight)
CompactHandView(
cards: playerCards,
cardsFaceUp: playerCardsFaceUp,
isWinner: playerIsWinner,
containerWidth: width,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
revealStyle: revealStyle,
isWaitingForReveal: isWaitingForReveal,
currentRevealIndex: currentRevealIndex,
revealProgress: revealProgress,
isPlayerHand: true,
onReveal: onReveal,
onUpdateProgress: onUpdateProgress
)
}
.frame(maxWidth: isDealing ? .infinity : width)
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Player hand"))
.accessibilityValue(playerHandDescription + (playerIsWinner ? ", " + String(localized: "Winner") : ""))
}
private func bankerHandSection(width: CGFloat) -> some View {
// Calculate value from face-up cards
let visibleValue: Int = {
guard !bankerCards.isEmpty else { return 0 }
let faceUpCards = zip(bankerCards, bankerCardsFaceUp)
.filter { $1 }
.map { $0.0 }
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
}()
let allCardsFaceUp = !bankerCards.isEmpty && bankerCardsFaceUp.count == bankerCards.count && bankerCardsFaceUp.allSatisfy({ $0 })
let _ = CasinoDesign.debugLog("🎯 Banker: cards=\(bankerCards.count), faceUp=\(bankerCardsFaceUp.count), allFaceUp=\(allCardsFaceUp), visibleValue=\(visibleValue)")
return VStack(spacing: Design.Spacing.small) {
HStack(spacing: Design.Spacing.small) {
Text("BANKER")
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(.white)
// Always show value when there are cards - it updates as cards become visible
if !bankerCards.isEmpty {
HandValueBadge(value: visibleValue, color: .red)
.animation(nil, value: visibleValue) // No animation when value changes
}
}
.frame(minHeight: labelRowMinHeight)
CompactHandView(
cards: bankerCards,
cardsFaceUp: bankerCardsFaceUp,
isWinner: bankerIsWinner,
containerWidth: width,
showAnimations: showAnimations,
dealingSpeed: dealingSpeed,
revealStyle: revealStyle,
isWaitingForReveal: isWaitingForReveal,
currentRevealIndex: currentRevealIndex,
revealProgress: revealProgress,
isPlayerHand: false,
onReveal: onReveal,
onUpdateProgress: onUpdateProgress
)
}
.frame(maxWidth: isDealing ? .infinity : width)
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Banker hand"))
.accessibilityValue(bankerHandDescription + (bankerIsWinner ? ", " + String(localized: "Winner") : ""))
}
}
// MARK: - Previews
#Preview("Horizontal - Betting Phase") {
ZStack {
TableBackgroundView()
CardsDisplayArea(
playerCards: [],
bankerCards: [],
playerCardsFaceUp: [],
bankerCardsFaceUp: [],
playerValue: 0,
bankerValue: 0,
playerIsWinner: false,
bankerIsWinner: false,
isTie: false,
showAnimations: true,
dealingSpeed: 1.0,
revealStyle: .auto,
isWaitingForReveal: false,
currentRevealIndex: 0,
revealProgress: 0,
bettedOnPlayer: nil,
isDealing: false,
screenSize: CGSize(width: 400, height: 800),
onReveal: {},
onUpdateProgress: { _ in }
)
}
}
#Preview("Vertical - Dealing (Bet on Player)") {
ZStack {
TableBackgroundView()
CardsDisplayArea(
playerCards: [
Card(suit: .spades, rank: .king),
Card(suit: .hearts, rank: .eight)
],
bankerCards: [
Card(suit: .clubs, rank: .seven),
Card(suit: .diamonds, rank: .five)
],
playerCardsFaceUp: [true, true],
bankerCardsFaceUp: [true, true],
playerValue: 8,
bankerValue: 2,
playerIsWinner: true,
bankerIsWinner: false,
isTie: false,
showAnimations: true,
dealingSpeed: 1.0,
revealStyle: .auto,
isWaitingForReveal: false,
currentRevealIndex: 0,
revealProgress: 0,
bettedOnPlayer: true,
isDealing: true,
screenSize: CGSize(width: 400, height: 800),
onReveal: {},
onUpdateProgress: { _ in }
)
}
}
#Preview("Vertical - Dealing (Bet on Banker)") {
ZStack {
TableBackgroundView()
CardsDisplayArea(
playerCards: [
Card(suit: .spades, rank: .four),
Card(suit: .hearts, rank: .three),
Card(suit: .clubs, rank: .two)
],
bankerCards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .diamonds, rank: .ace),
Card(suit: .spades, rank: .seven)
],
playerCardsFaceUp: [true, true, true],
bankerCardsFaceUp: [true, true, true],
playerValue: 9,
bankerValue: 9,
playerIsWinner: false,
bankerIsWinner: true,
isTie: false,
showAnimations: true,
dealingSpeed: 1.0,
revealStyle: .auto,
isWaitingForReveal: false,
currentRevealIndex: 0,
revealProgress: 0,
bettedOnPlayer: false,
isDealing: true,
screenSize: CGSize(width: 400, height: 800),
onReveal: {},
onUpdateProgress: { _ in }
)
}
}