CasinoGames/Blackjack/Views/BlackjackTableView.swift

499 lines
18 KiB
Swift

//
// BlackjackTableView.swift
// Blackjack
//
// The main table layout showing dealer and player hands.
//
import SwiftUI
import CasinoKit
struct BlackjackTableView: View {
@Bindable var state: GameState
let onPlaceBet: () -> Void
// MARK: - Scaled Metrics
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium
@ScaledMetric(relativeTo: .title) private var valueFontSize: CGFloat = Design.BaseFontSize.xLarge
@ScaledMetric(relativeTo: .caption) private var hintFontSize: CGFloat = Design.BaseFontSize.small
// MARK: - Layout
private let cardWidth: CGFloat = Design.Size.cardWidth
private let cardSpacing: CGFloat = Design.Size.cardOverlap
var body: some View {
VStack(spacing: Design.Spacing.large) {
// Dealer area
DealerHandView(
hand: state.dealerHand,
showHoleCard: shouldShowDealerHoleCard,
cardWidth: cardWidth,
cardSpacing: cardSpacing
)
Spacer()
// Insurance zone (when offered)
if state.currentPhase == .insurance {
InsuranceZoneView(
betAmount: state.currentBet / 2,
balance: state.balance,
onTake: { Task { await state.takeInsurance() } },
onDecline: { state.declineInsurance() }
)
.transition(.scale.combined(with: .opacity))
}
// Player hands area
PlayerHandsView(
hands: state.playerHands,
activeHandIndex: state.activeHandIndex,
isPlayerTurn: isPlayerTurn,
cardWidth: cardWidth,
cardSpacing: cardSpacing
)
// Betting zone (when betting)
if state.currentPhase == .betting {
BettingZoneView(
betAmount: state.currentBet,
minBet: state.settings.minBet,
maxBet: state.settings.maxBet,
onTap: onPlaceBet
)
.transition(.scale.combined(with: .opacity))
}
// Hint (when enabled and player turn)
if state.settings.showHints && isPlayerTurn, let hint = currentHint {
HintView(hint: hint)
.transition(.opacity)
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.medium)
.animation(.spring(duration: Design.Animation.springDuration), value: state.currentPhase)
}
// MARK: - Computed Properties
private var shouldShowDealerHoleCard: Bool {
switch state.currentPhase {
case .dealerTurn, .roundComplete:
return true
default:
return false
}
}
private var isPlayerTurn: Bool {
if case .playerTurn = state.currentPhase {
return true
}
return false
}
private var currentHint: String? {
guard let hand = state.activeHand,
let upCard = state.dealerUpCard else { return nil }
return state.engine.getHint(playerHand: hand, dealerUpCard: upCard)
}
}
// MARK: - Dealer Hand View
struct DealerHandView: View {
let hand: BlackjackHand
let showHoleCard: Bool
let cardWidth: CGFloat
let cardSpacing: CGFloat
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium
var body: some View {
VStack(spacing: Design.Spacing.small) {
// Label and value
HStack(spacing: Design.Spacing.small) {
Text(String(localized: "DEALER"))
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if !hand.cards.isEmpty && showHoleCard {
ValueBadge(value: hand.value, color: Color.Hand.dealer)
}
}
// Cards
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
if hand.cards.isEmpty {
CardPlaceholderView(width: cardWidth)
CardPlaceholderView(width: cardWidth)
} else {
ForEach(hand.cards.indices, id: \.self) { index in
let isFaceUp = index == 0 || showHoleCard
CardView(
card: hand.cards[index],
isFaceUp: isFaceUp,
cardWidth: cardWidth
)
.zIndex(Double(index))
}
}
}
// Result badge
if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil {
Text(result)
.font(.system(size: labelFontSize, weight: .black))
.foregroundStyle(handResultColor)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.xSmall)
.background(
Capsule()
.fill(handResultColor.opacity(Design.Opacity.hint))
)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(dealerAccessibilityLabel)
}
private var handResultText: String? {
if hand.isBlackjack {
return String(localized: "BLACKJACK")
}
if hand.isBusted {
return String(localized: "BUST")
}
return nil
}
private var handResultColor: Color {
if hand.isBlackjack { return .yellow }
if hand.isBusted { return .green } // Good for player
return .white
}
private var dealerAccessibilityLabel: String {
if hand.cards.isEmpty {
return String(localized: "Dealer: No cards")
}
let visibleCards = showHoleCard ? hand.cards : [hand.cards[0]]
let cardsDescription = visibleCards.map { $0.accessibilityDescription }.joined(separator: ", ")
return String(localized: "Dealer: \(cardsDescription). Value: \(showHoleCard ? String(hand.value) : "hidden")")
}
}
// MARK: - Player Hands View
struct PlayerHandsView: View {
let hands: [BlackjackHand]
let activeHandIndex: Int
let isPlayerTurn: Bool
let cardWidth: CGFloat
let cardSpacing: CGFloat
/// Adaptive card width based on number of hands (smaller cards for more splits)
private var adaptiveCardWidth: CGFloat {
switch hands.count {
case 1, 2:
return cardWidth
case 3:
return cardWidth * 0.90
default: // 4+ hands
return cardWidth * 0.85
}
}
/// Adaptive spacing based on number of hands
private var adaptiveSpacing: CGFloat {
switch hands.count {
case 1, 2:
return Design.Spacing.xxLarge
case 3:
return Design.Spacing.large
default: // 4+ hands
return Design.Spacing.medium
}
}
var body: some View {
HStack(spacing: adaptiveSpacing) {
// Display hands in reverse order (right to left play order)
// So hand 0 (played first) appears on the right
ForEach(hands.indices.reversed(), id: \.self) { index in
PlayerHandView(
hand: hands[index],
isActive: index == activeHandIndex && isPlayerTurn,
// Hand numbers: rightmost is Hand 1, leftmost is Hand 2, etc.
handNumber: hands.count > 1 ? hands.count - index : nil,
cardWidth: adaptiveCardWidth,
cardSpacing: cardSpacing
)
}
}
.frame(maxWidth: .infinity) // Center the hands horizontally
}
}
struct PlayerHandView: View {
let hand: BlackjackHand
let isActive: Bool
let handNumber: Int?
let cardWidth: CGFloat
let cardSpacing: CGFloat
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium
@ScaledMetric(relativeTo: .caption) private var handNumberSize: CGFloat = Design.BaseFontSize.small
var body: some View {
VStack(spacing: Design.Spacing.small) {
// Cards with container - uses dynamic sizing based on card count
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
if hand.cards.isEmpty {
CardPlaceholderView(width: cardWidth)
CardPlaceholderView(width: cardWidth)
} else {
ForEach(hand.cards.indices, id: \.self) { index in
CardView(
card: hand.cards[index],
isFaceUp: true,
cardWidth: cardWidth
)
.zIndex(Double(index))
}
}
}
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.Table.feltDark.opacity(Design.Opacity.light))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.strokeBorder(
isActive ? Color.Hand.active : Color.white.opacity(Design.Opacity.hint),
lineWidth: isActive ? Design.LineWidth.thick : Design.LineWidth.thin
)
)
)
.contentShape(Rectangle()) // Ensure tap area matches visual
.animation(.easeInOut(duration: Design.Animation.quick), value: isActive)
// Hand info
HStack(spacing: Design.Spacing.small) {
if let number = handNumber {
Text(String(localized: "Hand \(number)"))
.font(.system(size: handNumberSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
if !hand.cards.isEmpty {
Text(hand.valueDisplay)
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(valueColor)
}
if hand.isDoubledDown {
Image(systemName: "xmark.circle.fill")
.font(.system(size: handNumberSize))
.foregroundStyle(.purple)
}
}
// Result badge
if let result = hand.result {
Text(result.displayText)
.font(.system(size: labelFontSize, weight: .black))
.foregroundStyle(result.color)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.xSmall)
.background(
Capsule()
.fill(result.color.opacity(Design.Opacity.hint))
)
}
// Bet amount
if hand.bet > 0 {
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "dollarsign.circle.fill")
.foregroundStyle(.yellow)
Text("\(hand.bet * (hand.isDoubledDown ? 2 : 1))")
.font(.system(size: handNumberSize, weight: .bold, design: .rounded))
.foregroundStyle(.yellow)
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(playerAccessibilityLabel)
}
private var valueColor: Color {
if hand.isBlackjack { return .yellow }
if hand.isBusted { return .red }
if hand.value == 21 { return .green }
return .white
}
private var playerAccessibilityLabel: String {
let cardsDescription = hand.cards.map { $0.accessibilityDescription }.joined(separator: ", ")
var label = String(localized: "Player hand: \(cardsDescription). Value: \(hand.valueDisplay)")
if let result = hand.result {
label += ". \(result.displayText)"
}
return label
}
}
// MARK: - Betting Zone View
struct BettingZoneView: View {
let betAmount: Int
let minBet: Int
let maxBet: Int
let onTap: () -> Void
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.large
private var isAtMax: Bool {
betAmount >= maxBet
}
var body: some View {
Button(action: onTap) {
ZStack {
// Background
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.BettingZone.main)
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(Color.BettingZone.mainBorder, lineWidth: Design.LineWidth.medium)
)
// Content
if betAmount > 0 {
// Show chip with amount
ChipOnTableView(amount: betAmount, showMax: isAtMax)
} else {
// Empty state
VStack(spacing: Design.Spacing.small) {
Text(String(localized: "TAP TO BET"))
.font(.system(size: labelFontSize, weight: .bold))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
HStack(spacing: Design.Spacing.medium) {
Text(String(localized: "Min: $\(minBet)"))
.font(.system(size: Design.BaseFontSize.small, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.light))
Text(String(localized: "Max: $\(maxBet.formatted())"))
.font(.system(size: Design.BaseFontSize.small, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.light))
}
}
}
}
.frame(maxWidth: .infinity)
.frame(height: Design.Size.bettingZoneHeight)
}
.buttonStyle(.plain)
.accessibilityLabel(betAmount > 0 ? "$\(betAmount) bet" + (isAtMax ? ", maximum" : "") : "Place bet")
.accessibilityHint("Double tap to add chips")
}
}
// MARK: - Insurance Zone View
struct InsuranceZoneView: View {
let betAmount: Int
let balance: Int
let onTake: () -> Void
let onDecline: () -> Void
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium
@ScaledMetric(relativeTo: .body) private var buttonFontSize: CGFloat = Design.BaseFontSize.body
var body: some View {
VStack(spacing: Design.Spacing.medium) {
Text(String(localized: "INSURANCE?"))
.font(.system(size: labelFontSize, weight: .bold))
.foregroundStyle(.yellow)
Text(String(localized: "Dealer showing Ace"))
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
HStack(spacing: Design.Spacing.large) {
Button(action: onDecline) {
Text(String(localized: "No"))
.font(.system(size: buttonFontSize, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.vertical, Design.Spacing.medium)
.background(
Capsule()
.fill(Color.Button.surrender)
)
}
if balance >= betAmount {
Button(action: onTake) {
Text(String(localized: "Yes ($\(betAmount))"))
.font(.system(size: buttonFontSize, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.vertical, Design.Spacing.medium)
.background(
Capsule()
.fill(Color.Button.insurance)
)
}
}
}
}
.padding(Design.Spacing.large)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.fill(Color.BettingZone.insurance.opacity(Design.Opacity.heavy))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(Color.BettingZone.insuranceBorder, lineWidth: Design.LineWidth.medium)
)
)
}
}
// MARK: - Hint View
struct HintView: View {
let hint: String
var body: some View {
HStack(spacing: Design.Spacing.small) {
Image(systemName: "lightbulb.fill")
.foregroundStyle(.yellow)
Text(String(localized: "Hint: \(hint)"))
.font(.system(size: Design.BaseFontSize.small, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
}
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(
Capsule()
.fill(Color.black.opacity(Design.Opacity.light))
)
}
}
// MARK: - Card Accessibility Extension
extension Card {
var accessibilityDescription: String {
"\(rank.accessibilityName) of \(suit.accessibilityName)"
}
}