CasinoGames/Blackjack/Views/PlayerHandView.swift

249 lines
9.1 KiB
Swift

//
// PlayerHandView.swift
// Blackjack
//
// Displays player hands in a horizontally scrollable container.
//
import SwiftUI
import CasinoKit
// MARK: - Player Hands Container
/// Container for multiple player hands with horizontal scrolling.
struct PlayerHandsView: View {
let hands: [BlackjackHand]
let activeHandIndex: Int
let isPlayerTurn: Bool
let showCardCount: Bool
let cardWidth: CGFloat
let cardSpacing: CGFloat
var body: some View {
GeometryReader { geometry in
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Design.Spacing.large) {
// Display hands in reverse order (right to left play order)
// Visual order: Hand 3, Hand 2, Hand 1 (left to right)
// Play order: Hand 1 played first (rightmost), then Hand 2, etc.
ForEach(hands.indices.reversed(), id: \.self) { index in
PlayerHandView(
hand: hands[index],
isActive: index == activeHandIndex && isPlayerTurn,
showCardCount: showCardCount,
// Hand numbers: rightmost (index 0) is Hand 1, played first
handNumber: hands.count > 1 ? index + 1 : nil,
cardWidth: cardWidth,
cardSpacing: cardSpacing
)
.id(index)
}
}
.padding(.horizontal, Design.Spacing.medium)
.frame(minWidth: geometry.size.width)
}
.scrollClipDisabled()
.onChange(of: activeHandIndex) { _, newIndex in
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
proxy.scrollTo(newIndex, anchor: .center)
}
}
.onAppear {
if hands.count > 1 {
proxy.scrollTo(activeHandIndex, anchor: .center)
}
}
}
}
.frame(height: Design.Size.playerHandsHeight)
}
}
// MARK: - Single Player Hand
/// Displays a single player hand with cards, value, and result.
struct PlayerHandView: View {
let hand: BlackjackHand
let isActive: Bool
let showCardCount: Bool
let handNumber: Int?
let cardWidth: CGFloat
let cardSpacing: CGFloat
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
@ScaledMetric(relativeTo: .caption) private var handNumberSize: CGFloat = Design.Size.handNumberFontSize
var body: some View {
VStack(spacing: Design.Spacing.small) {
// Cards with container
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
)
.overlay(alignment: .bottomLeading) {
if showCardCount {
HiLoCountBadge(card: hand.cards[index])
}
}
.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())
.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: Design.Size.handIconSize))
.foregroundStyle(.purple)
}
}
// Result badge
if let result = hand.result {
Text(result.displayText)
.font(.system(size: labelFontSize, weight: .black))
.foregroundStyle(result.color)
.padding(.horizontal, Design.Size.hintPaddingH)
.padding(.vertical, Design.Size.hintPaddingV)
.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")
.font(.system(size: Design.Size.handIconSize))
.foregroundStyle(.yellow)
Text("\(hand.bet * (hand.isDoubledDown ? 2 : 1))")
.font(.system(size: handNumberSize, weight: .bold, design: .rounded))
.foregroundStyle(.yellow)
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(playerAccessibilityLabel)
}
// MARK: - Computed Properties
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: - Previews
#Preview("Single Hand - Empty") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerHandsView(
hands: [BlackjackHand()],
activeHandIndex: 0,
isPlayerTurn: true,
showCardCount: false,
cardWidth: 60,
cardSpacing: -20
)
}
}
#Preview("Single Hand - Cards") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerHandsView(
hands: [BlackjackHand(cards: [
Card(suit: .clubs, rank: .eight),
Card(suit: .hearts, rank: .nine)
], bet: 100)],
activeHandIndex: 0,
isPlayerTurn: true,
showCardCount: false,
cardWidth: 60,
cardSpacing: -20
)
}
}
#Preview("Split Hands") {
ZStack {
Color.Table.felt.ignoresSafeArea()
PlayerHandsView(
hands: [
BlackjackHand(cards: [
Card(suit: .clubs, rank: .eight),
Card(suit: .spades, rank: .jack)
], bet: 100),
BlackjackHand(cards: [
Card(suit: .hearts, rank: .eight),
Card(suit: .diamonds, rank: .five)
], bet: 100),
BlackjackHand(cards: [
Card(suit: .hearts, rank: .eight),
Card(suit: .diamonds, rank: .five)
], bet: 100),
BlackjackHand(cards: [
Card(suit: .hearts, rank: .eight),
Card(suit: .diamonds, rank: .five)
], bet: 100)
],
activeHandIndex: 1,
isPlayerTurn: true,
showCardCount: true,
cardWidth: 60,
cardSpacing: -20
)
}
}