265 lines
9.8 KiB
Swift
265 lines
9.8 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
|
|
|
|
/// Total card count across all hands - used to trigger scroll when hitting
|
|
private var totalCardCount: Int {
|
|
hands.reduce(0) { $0 + $1.cards.count }
|
|
}
|
|
|
|
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.large)
|
|
.frame(minWidth: geometry.size.width)
|
|
}
|
|
.scrollClipDisabled()
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
.onChange(of: activeHandIndex) { _, newIndex in
|
|
scrollToHand(proxy: proxy, index: newIndex)
|
|
}
|
|
.onChange(of: totalCardCount) { _, _ in
|
|
// Scroll to active hand when cards are added (hit)
|
|
scrollToHand(proxy: proxy, index: activeHandIndex)
|
|
}
|
|
.onChange(of: hands.count) { _, _ in
|
|
// Scroll to active hand when split occurs
|
|
scrollToHand(proxy: proxy, index: activeHandIndex)
|
|
}
|
|
.onAppear {
|
|
scrollToHand(proxy: proxy, index: activeHandIndex)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: Design.Size.playerHandsHeight)
|
|
}
|
|
|
|
private func scrollToHand(proxy: ScrollViewProxy, index: Int) {
|
|
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
|
|
proxy.scrollTo(index, anchor: .center)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
)
|
|
}
|
|
}
|
|
|