Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
e455a5bda8
commit
644127ae40
@ -514,8 +514,8 @@
|
|||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -548,8 +548,8 @@
|
|||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@ -206,7 +206,7 @@ final class GameState {
|
|||||||
SavedRoundResult(
|
SavedRoundResult(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
mainResult: result.mainHandResult.saveName,
|
mainResult: result.mainHandResult.saveName,
|
||||||
hadSplit: result.splitHandResult != nil,
|
hadSplit: result.hadSplit,
|
||||||
totalWinnings: result.totalWinnings
|
totalWinnings: result.totalWinnings
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -628,10 +628,10 @@ final class GameState {
|
|||||||
bustCount += 1
|
bustCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create round result
|
// Create round result with all hand results
|
||||||
|
let allHandResults = playerHands.map { $0.result ?? .lose }
|
||||||
lastRoundResult = RoundResult(
|
lastRoundResult = RoundResult(
|
||||||
mainHandResult: playerHands[0].result ?? .lose,
|
handResults: allHandResults,
|
||||||
splitHandResult: playerHands.count > 1 ? playerHands[1].result : nil,
|
|
||||||
insuranceResult: insuranceBet > 0 ? (dealerHand.isBlackjack ? .insuranceWin : .insuranceLose) : nil,
|
insuranceResult: insuranceBet > 0 ? (dealerHand.isBlackjack ? .insuranceWin : .insuranceLose) : nil,
|
||||||
totalWinnings: roundWinnings,
|
totalWinnings: roundWinnings,
|
||||||
wasBlackjack: wasBlackjack
|
wasBlackjack: wasBlackjack
|
||||||
|
|||||||
@ -66,10 +66,31 @@ enum HandResult: Equatable {
|
|||||||
|
|
||||||
/// Overall game result for the round.
|
/// Overall game result for the round.
|
||||||
struct RoundResult: Equatable {
|
struct RoundResult: Equatable {
|
||||||
let mainHandResult: HandResult
|
/// Results for all player hands (index 0 = Hand 1, index 1 = Hand 2, etc.)
|
||||||
let splitHandResult: HandResult?
|
let handResults: [HandResult]
|
||||||
let insuranceResult: HandResult?
|
let insuranceResult: HandResult?
|
||||||
let totalWinnings: Int
|
let totalWinnings: Int
|
||||||
let wasBlackjack: Bool
|
let wasBlackjack: Bool
|
||||||
|
|
||||||
|
/// The main/best result for display purposes (first hand, or best if split)
|
||||||
|
var mainHandResult: HandResult {
|
||||||
|
// Return the best result for the headline
|
||||||
|
if wasBlackjack { return .blackjack }
|
||||||
|
if handResults.contains(.win) { return .win }
|
||||||
|
if handResults.contains(.push) { return .push }
|
||||||
|
if handResults.contains(.surrender) { return .surrender }
|
||||||
|
if handResults.allSatisfy({ $0 == .bust }) { return .bust }
|
||||||
|
return handResults.first ?? .lose
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this round had split hands
|
||||||
|
var hadSplit: Bool {
|
||||||
|
handResults.count > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy accessor for backwards compatibility
|
||||||
|
var splitHandResult: HandResult? {
|
||||||
|
handResults.count > 1 ? handResults[1] : nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -61,8 +61,9 @@ struct BlackjackHand: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this hand can hit.
|
/// Whether this hand can hit.
|
||||||
|
/// Note: Standard Blackjack has NO card limit - you can hit until you bust or stand.
|
||||||
var canHit: Bool {
|
var canHit: Bool {
|
||||||
!isBusted && !isStanding && !isBlackjack && cards.count < 5
|
!isBusted && !isStanding && !isBlackjack
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates both hard and soft values.
|
/// Calculates both hard and soft values.
|
||||||
|
|||||||
@ -745,6 +745,28 @@
|
|||||||
"comment" : "Description of a deck count option when the user selects 4 decks.",
|
"comment" : "Description of a deck count option when the user selects 4 decks.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Cost: $%lld (half your bet)" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Cost: $%lld (half your bet)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Costo: $%lld (mitad de tu apuesta)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Coût: %lld$ (la moitié de votre mise)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Costs half your original bet." : {
|
"Costs half your original bet." : {
|
||||||
"comment" : "Description of the cost of insurance in the rules help view.",
|
"comment" : "Description of the cost of insurance in the rules help view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -1316,6 +1338,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Hint" : {
|
||||||
|
"comment" : "A general label for a gameplay hint.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Hint: %@" : {
|
"Hint: %@" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -1750,6 +1776,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"No" : {
|
"No" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -1798,6 +1825,28 @@
|
|||||||
},
|
},
|
||||||
"No surrender option." : {
|
"No surrender option." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"No Thanks" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "No Thanks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "No, gracias"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Non merci"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Objective" : {
|
"Objective" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -2316,6 +2365,7 @@
|
|||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Split Hand" : {
|
"Split Hand" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
@ -118,6 +118,7 @@ extension Color {
|
|||||||
// MARK: - Modal Colors
|
// MARK: - Modal Colors
|
||||||
|
|
||||||
enum Modal {
|
enum Modal {
|
||||||
|
static let background = Color(red: 0.12, green: 0.18, blue: 0.25)
|
||||||
static let backgroundLight = Color(red: 0.15, green: 0.2, blue: 0.3)
|
static let backgroundLight = Color(red: 0.15, green: 0.2, blue: 0.3)
|
||||||
static let backgroundDark = Color(red: 0.1, green: 0.15, blue: 0.25)
|
static let backgroundDark = Color(red: 0.1, green: 0.15, blue: 0.25)
|
||||||
}
|
}
|
||||||
|
|||||||
106
Blackjack/Views/BettingZoneView.swift
Normal file
106
Blackjack/Views/BettingZoneView.swift
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
//
|
||||||
|
// BettingZoneView.swift
|
||||||
|
// Blackjack
|
||||||
|
//
|
||||||
|
// The betting area where players place their bets.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CasinoKit
|
||||||
|
|
||||||
|
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: - Previews
|
||||||
|
|
||||||
|
#Preview("Empty") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
BettingZoneView(
|
||||||
|
betAmount: 0,
|
||||||
|
minBet: 10,
|
||||||
|
maxBet: 1000,
|
||||||
|
onTap: {}
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("With Bet") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
BettingZoneView(
|
||||||
|
betAmount: 250,
|
||||||
|
minBet: 10,
|
||||||
|
maxBet: 1000,
|
||||||
|
onTap: {}
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Max Bet") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
BettingZoneView(
|
||||||
|
betAmount: 1000,
|
||||||
|
minBet: 10,
|
||||||
|
maxBet: 1000,
|
||||||
|
onTap: {}
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -39,17 +39,6 @@ struct BlackjackTableView: View {
|
|||||||
|
|
||||||
Spacer()
|
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
|
// Player hands area
|
||||||
PlayerHandsView(
|
PlayerHandsView(
|
||||||
hands: state.playerHands,
|
hands: state.playerHands,
|
||||||
@ -145,508 +134,12 @@ struct BlackjackTableView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dealer Hand View
|
// MARK: - Previews
|
||||||
|
|
||||||
struct DealerHandView: View {
|
#Preview {
|
||||||
let hand: BlackjackHand
|
|
||||||
let showHoleCard: Bool
|
|
||||||
let showCardCount: 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)
|
|
||||||
|
|
||||||
// Show value: always show if hole card visible, or show single card value in European mode
|
|
||||||
if !hand.cards.isEmpty {
|
|
||||||
if showHoleCard {
|
|
||||||
ValueBadge(value: hand.value, color: Color.Hand.dealer)
|
|
||||||
} else if hand.cards.count == 1 {
|
|
||||||
// European mode: show single visible card value
|
|
||||||
ValueBadge(value: hand.cards[0].blackjackValue, 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
|
|
||||||
)
|
|
||||||
.overlay(alignment: .bottomLeading) {
|
|
||||||
if showCardCount && isFaceUp {
|
|
||||||
HiLoCountBadge(card: hand.cards[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.zIndex(Double(index))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show placeholder for second card in European mode (no hole card)
|
|
||||||
if hand.cards.count == 1 && !showHoleCard {
|
|
||||||
CardPlaceholderView(width: cardWidth)
|
|
||||||
.opacity(Design.Opacity.medium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 showCardCount: 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,
|
|
||||||
showCardCount: showCardCount,
|
|
||||||
// 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 showCardCount: 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
|
|
||||||
)
|
|
||||||
.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()) // 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 {
|
ZStack {
|
||||||
// Background
|
Color.Table.felt.ignoresSafeArea()
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
Text("Use GameTableView for full preview")
|
||||||
.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)
|
.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: - Betting Hint View
|
|
||||||
|
|
||||||
/// Shows betting recommendations based on the current count.
|
|
||||||
struct BettingHintView: View {
|
|
||||||
let hint: String
|
|
||||||
let trueCount: Double
|
|
||||||
|
|
||||||
private var hintColor: Color {
|
|
||||||
let tc = Int(trueCount.rounded())
|
|
||||||
if tc >= 2 {
|
|
||||||
return .green // Player advantage - bet more
|
|
||||||
} else if tc <= -1 {
|
|
||||||
return .red // House advantage - bet less
|
|
||||||
} else {
|
|
||||||
return .yellow // Neutral
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var icon: String {
|
|
||||||
let tc = Int(trueCount.rounded())
|
|
||||||
if tc >= 2 {
|
|
||||||
return "arrow.up.circle.fill" // Increase bet
|
|
||||||
} else if tc <= -1 {
|
|
||||||
return "arrow.down.circle.fill" // Decrease bet
|
|
||||||
} else {
|
|
||||||
return "equal.circle.fill" // Neutral
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: Design.Spacing.small) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.foregroundStyle(hintColor)
|
|
||||||
Text(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))
|
|
||||||
.overlay(
|
|
||||||
Capsule()
|
|
||||||
.strokeBorder(hintColor.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.accessibilityElement(children: .ignore)
|
|
||||||
.accessibilityLabel(String(localized: "Betting Hint"))
|
|
||||||
.accessibilityValue(hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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: - Hi-Lo Count Badge
|
|
||||||
|
|
||||||
/// A small badge showing the Hi-Lo counting value of a card.
|
|
||||||
struct HiLoCountBadge: View {
|
|
||||||
let card: Card
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(card.hiLoDisplayText)
|
|
||||||
.font(.system(size: Design.BaseFontSize.xxSmall, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(badgeTextColor)
|
|
||||||
.padding(.horizontal, Design.Spacing.xSmall)
|
|
||||||
.padding(.vertical, Design.Spacing.xxxSmall)
|
|
||||||
.background(
|
|
||||||
Capsule()
|
|
||||||
.fill(badgeBackgroundColor)
|
|
||||||
)
|
|
||||||
.offset(x: -Design.Spacing.xSmall, y: Design.Spacing.xSmall)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var badgeBackgroundColor: Color {
|
|
||||||
switch card.hiLoValue {
|
|
||||||
case 1: return .green // Low cards = positive for player
|
|
||||||
case -1: return .red // High cards = negative for player
|
|
||||||
default: return .gray // Neutral
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var badgeTextColor: Color {
|
|
||||||
.white
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Card Accessibility Extension
|
|
||||||
|
|
||||||
extension Card {
|
|
||||||
var accessibilityDescription: String {
|
|
||||||
"\(rank.accessibilityName) of \(suit.accessibilityName)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
157
Blackjack/Views/DealerHandView.swift
Normal file
157
Blackjack/Views/DealerHandView.swift
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
//
|
||||||
|
// DealerHandView.swift
|
||||||
|
// Blackjack
|
||||||
|
//
|
||||||
|
// Displays the dealer's hand with cards and value.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CasinoKit
|
||||||
|
|
||||||
|
struct DealerHandView: View {
|
||||||
|
let hand: BlackjackHand
|
||||||
|
let showHoleCard: Bool
|
||||||
|
let showCardCount: 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)
|
||||||
|
|
||||||
|
// Show value: always show if hole card visible, or show single card value in European mode
|
||||||
|
if !hand.cards.isEmpty {
|
||||||
|
if showHoleCard {
|
||||||
|
ValueBadge(value: hand.value, color: Color.Hand.dealer)
|
||||||
|
} else if hand.cards.count == 1 {
|
||||||
|
// European mode: show single visible card value
|
||||||
|
ValueBadge(value: hand.cards[0].blackjackValue, 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
|
||||||
|
)
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
if showCardCount && isFaceUp {
|
||||||
|
HiLoCountBadge(card: hand.cards[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.zIndex(Double(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show placeholder for second card in European mode (no hole card)
|
||||||
|
if hand.cards.count == 1 && !showHoleCard {
|
||||||
|
CardPlaceholderView(width: cardWidth)
|
||||||
|
.opacity(Design.Opacity.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
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")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Empty Hand") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
DealerHandView(
|
||||||
|
hand: BlackjackHand(),
|
||||||
|
showHoleCard: false,
|
||||||
|
showCardCount: false,
|
||||||
|
cardWidth: 60,
|
||||||
|
cardSpacing: -20
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Two Cards - Hole Hidden") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
DealerHandView(
|
||||||
|
hand: BlackjackHand(cards: [
|
||||||
|
Card(suit: .spades, rank: .ace),
|
||||||
|
Card(suit: .hearts, rank: .king)
|
||||||
|
]),
|
||||||
|
showHoleCard: false,
|
||||||
|
showCardCount: false,
|
||||||
|
cardWidth: 60,
|
||||||
|
cardSpacing: -20
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Blackjack - Revealed") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
DealerHandView(
|
||||||
|
hand: BlackjackHand(cards: [
|
||||||
|
Card(suit: .spades, rank: .ace),
|
||||||
|
Card(suit: .hearts, rank: .king)
|
||||||
|
]),
|
||||||
|
showHoleCard: true,
|
||||||
|
showCardCount: true,
|
||||||
|
cardWidth: 60,
|
||||||
|
cardSpacing: -20
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -111,15 +111,18 @@ struct GameTableView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Chip selector
|
// Chip selector - only interactive during betting phase
|
||||||
|
// During gameplay, show chips as they were (balance + currentBet) but dimmed
|
||||||
ChipSelectorView(
|
ChipSelectorView(
|
||||||
selectedChip: $selectedChip,
|
selectedChip: $selectedChip,
|
||||||
balance: state.balance,
|
balance: state.currentPhase == .betting ? state.balance : (state.balance + state.currentBet),
|
||||||
currentBet: state.currentBet,
|
currentBet: state.currentPhase == .betting ? state.currentBet : 0,
|
||||||
maxBet: state.settings.maxBet
|
maxBet: state.settings.maxBet
|
||||||
)
|
)
|
||||||
.frame(maxWidth: maxContentWidth)
|
.frame(maxWidth: maxContentWidth)
|
||||||
.padding(.bottom, Design.Spacing.small)
|
.padding(.bottom, Design.Spacing.small)
|
||||||
|
.opacity(state.currentPhase == .betting ? 1.0 : Design.Opacity.medium)
|
||||||
|
.allowsHitTesting(state.currentPhase == .betting) // Disable interaction when not betting
|
||||||
|
|
||||||
// Action buttons
|
// Action buttons
|
||||||
ActionButtonsView(state: state)
|
ActionButtonsView(state: state)
|
||||||
@ -128,6 +131,17 @@ struct GameTableView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
// Insurance popup overlay (covers entire screen)
|
||||||
|
if state.currentPhase == .insurance {
|
||||||
|
InsurancePopupView(
|
||||||
|
betAmount: state.currentBet / 2,
|
||||||
|
balance: state.balance,
|
||||||
|
onTake: { Task { await state.takeInsurance() } },
|
||||||
|
onDecline: { state.declineInsurance() }
|
||||||
|
)
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||||
|
}
|
||||||
|
|
||||||
// Result banner overlay
|
// Result banner overlay
|
||||||
if state.showResultBanner, let result = state.lastRoundResult {
|
if state.showResultBanner, let result = state.lastRoundResult {
|
||||||
ResultBannerView(
|
ResultBannerView(
|
||||||
|
|||||||
81
Blackjack/Views/HiLoCountBadge.swift
Normal file
81
Blackjack/Views/HiLoCountBadge.swift
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
//
|
||||||
|
// HiLoCountBadge.swift
|
||||||
|
// Blackjack
|
||||||
|
//
|
||||||
|
// Badge showing the Hi-Lo counting value of a card.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CasinoKit
|
||||||
|
|
||||||
|
/// A small badge showing the Hi-Lo counting value of a card.
|
||||||
|
struct HiLoCountBadge: View {
|
||||||
|
let card: Card
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(card.hiLoDisplayText)
|
||||||
|
.font(.system(size: Design.BaseFontSize.xxSmall, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(badgeTextColor)
|
||||||
|
.padding(.horizontal, Design.Spacing.xSmall)
|
||||||
|
.padding(.vertical, Design.Spacing.xxxSmall)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(badgeBackgroundColor)
|
||||||
|
)
|
||||||
|
.offset(x: -Design.Spacing.xSmall, y: Design.Spacing.xSmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var badgeBackgroundColor: Color {
|
||||||
|
switch card.hiLoValue {
|
||||||
|
case 1: return .green // Low cards = positive for player
|
||||||
|
case -1: return .red // High cards = negative for player
|
||||||
|
default: return .gray // Neutral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var badgeTextColor: Color {
|
||||||
|
.white
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Card Accessibility Extension
|
||||||
|
|
||||||
|
extension Card {
|
||||||
|
/// Accessibility description for VoiceOver.
|
||||||
|
var accessibilityDescription: String {
|
||||||
|
"\(rank.accessibilityName) of \(suit.accessibilityName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews
|
||||||
|
|
||||||
|
#Preview("Low Card (+1)") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
CardView(card: Card(suit: .hearts, rank: .five), isFaceUp: true, cardWidth: 70)
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
HiLoCountBadge(card: Card(suit: .hearts, rank: .five))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("High Card (-1)") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
CardView(card: Card(suit: .spades, rank: .king), isFaceUp: true, cardWidth: 70)
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
HiLoCountBadge(card: Card(suit: .spades, rank: .king))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Neutral Card (0)") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
CardView(card: Card(suit: .diamonds, rank: .seven), isFaceUp: true, cardWidth: 70)
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
HiLoCountBadge(card: Card(suit: .diamonds, rank: .seven))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
126
Blackjack/Views/HintViews.swift
Normal file
126
Blackjack/Views/HintViews.swift
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
//
|
||||||
|
// HintViews.swift
|
||||||
|
// Blackjack
|
||||||
|
//
|
||||||
|
// Views for displaying game hints and betting recommendations.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CasinoKit
|
||||||
|
|
||||||
|
// MARK: - General Hint View
|
||||||
|
|
||||||
|
/// Displays a gameplay hint (hit, stand, double, etc.)
|
||||||
|
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))
|
||||||
|
)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(String(localized: "Hint"))
|
||||||
|
.accessibilityValue(hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Betting Hint View
|
||||||
|
|
||||||
|
/// Shows betting recommendations based on the current card count.
|
||||||
|
struct BettingHintView: View {
|
||||||
|
let hint: String
|
||||||
|
let trueCount: Double
|
||||||
|
|
||||||
|
private var hintColor: Color {
|
||||||
|
let tc = Int(trueCount.rounded())
|
||||||
|
if tc >= 2 {
|
||||||
|
return .green // Player advantage - bet more
|
||||||
|
} else if tc <= -1 {
|
||||||
|
return .red // House advantage - bet less
|
||||||
|
} else {
|
||||||
|
return .yellow // Neutral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var icon: String {
|
||||||
|
let tc = Int(trueCount.rounded())
|
||||||
|
if tc >= 2 {
|
||||||
|
return "arrow.up.circle.fill" // Increase bet
|
||||||
|
} else if tc <= -1 {
|
||||||
|
return "arrow.down.circle.fill" // Decrease bet
|
||||||
|
} else {
|
||||||
|
return "equal.circle.fill" // Neutral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(hintColor)
|
||||||
|
Text(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))
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.strokeBorder(hintColor.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(String(localized: "Betting Hint"))
|
||||||
|
.accessibilityValue(hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews
|
||||||
|
|
||||||
|
#Preview("Game Hint - Hit") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
HintView(hint: "Hit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Game Hint - Stand") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
HintView(hint: "Stand")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Betting Hint - Positive Count") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
BettingHintView(hint: "Bet 4x minimum", trueCount: 2.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Betting Hint - Negative Count") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
BettingHintView(hint: "Bet minimum", trueCount: -1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Betting Hint - Neutral") {
|
||||||
|
ZStack {
|
||||||
|
Color.Table.felt.ignoresSafeArea()
|
||||||
|
BettingHintView(hint: "Bet minimum (neutral)", trueCount: 0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
119
Blackjack/Views/InsurancePopupView.swift
Normal file
119
Blackjack/Views/InsurancePopupView.swift
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
//
|
||||||
|
// InsurancePopupView.swift
|
||||||
|
// Blackjack
|
||||||
|
//
|
||||||
|
// Modal popup for insurance decision when dealer shows an Ace.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CasinoKit
|
||||||
|
|
||||||
|
struct InsurancePopupView: View {
|
||||||
|
let betAmount: Int
|
||||||
|
let balance: Int
|
||||||
|
let onTake: () -> Void
|
||||||
|
let onDecline: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Dimmed background
|
||||||
|
Color.black.opacity(Design.Opacity.medium)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onTapGesture { } // Prevent taps passing through
|
||||||
|
|
||||||
|
// Popup card
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
// Icon
|
||||||
|
Image(systemName: "shield.fill")
|
||||||
|
.font(.system(size: Design.IconSize.xLarge))
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Text(String(localized: "INSURANCE?"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
Text(String(localized: "Dealer showing Ace"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.medium))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||||
|
|
||||||
|
// Cost info
|
||||||
|
Text(String(localized: "Cost: $\(betAmount) (half your bet)"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.small))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
.padding(.bottom, Design.Spacing.small)
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
HStack(spacing: Design.Spacing.large) {
|
||||||
|
// Decline button
|
||||||
|
Button(action: onDecline) {
|
||||||
|
Text(String(localized: "No Thanks"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.red.opacity(Design.Opacity.heavy))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept button (only if can afford)
|
||||||
|
if balance >= betAmount {
|
||||||
|
Button(action: onTake) {
|
||||||
|
Text(String(localized: "Yes ($\(betAmount))"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.medium, weight: .bold))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.Button.goldLight, Color.Button.goldDark],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.xLarge)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge)
|
||||||
|
.fill(Color.Modal.background)
|
||||||
|
.shadow(color: .black.opacity(Design.Opacity.medium), radius: Design.Shadow.radiusXLarge)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge)
|
||||||
|
.strokeBorder(Color.yellow.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityAddTraits(.isModal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Previews
|
||||||
|
|
||||||
|
#Preview("Can Afford") {
|
||||||
|
InsurancePopupView(
|
||||||
|
betAmount: 500,
|
||||||
|
balance: 4500,
|
||||||
|
onTake: {},
|
||||||
|
onDecline: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Cannot Afford") {
|
||||||
|
InsurancePopupView(
|
||||||
|
betAmount: 500,
|
||||||
|
balance: 200,
|
||||||
|
onTake: {},
|
||||||
|
onDecline: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
247
Blackjack/Views/PlayerHandView.swift
Normal file
247
Blackjack/Views/PlayerHandView.swift
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
//
|
||||||
|
// 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: 180)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.BaseFontSize.medium
|
||||||
|
@ScaledMetric(relativeTo: .caption) private var handNumberSize: CGFloat = Design.BaseFontSize.small
|
||||||
|
|
||||||
|
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: 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -68,19 +68,22 @@ struct ResultBannerView: View {
|
|||||||
.font(.system(size: amountFontSize, weight: .bold, design: .rounded))
|
.font(.system(size: amountFontSize, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(winningsColor)
|
.foregroundStyle(winningsColor)
|
||||||
|
|
||||||
// Breakdown
|
// Breakdown - all hands
|
||||||
VStack(spacing: Design.Spacing.small) {
|
VStack(spacing: Design.Spacing.small) {
|
||||||
ResultRow(label: String(localized: "Main Hand"), result: result.mainHandResult)
|
ForEach(result.handResults.indices, id: \.self) { index in
|
||||||
|
let handResult = result.handResults[index]
|
||||||
if let splitResult = result.splitHandResult {
|
// Hand numbering: index 0 = Hand 1 (played first, displayed rightmost)
|
||||||
ResultRow(label: String(localized: "Split Hand"), result: splitResult)
|
let handLabel = result.handResults.count > 1
|
||||||
|
? String(localized: "Hand \(index + 1)")
|
||||||
|
: String(localized: "Main Hand")
|
||||||
|
ResultRow(label: handLabel, result: handResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let insuranceResult = result.insuranceResult {
|
if let insuranceResult = result.insuranceResult {
|
||||||
ResultRow(label: String(localized: "Insurance"), result: insuranceResult)
|
ResultRow(label: String(localized: "Insurance"), result: insuranceResult)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(Design.Spacing.medium)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
.fill(Color.white.opacity(Design.Opacity.subtle))
|
.fill(Color.white.opacity(Design.Opacity.subtle))
|
||||||
@ -158,6 +161,7 @@ struct ResultBannerView: View {
|
|||||||
)
|
)
|
||||||
.shadow(color: mainResultColor.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXLarge)
|
.shadow(color: mainResultColor.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXLarge)
|
||||||
.frame(maxWidth: Design.Size.maxModalWidth)
|
.frame(maxWidth: Design.Size.maxModalWidth)
|
||||||
|
.padding(.horizontal, Design.Spacing.large) // Prevent clipping on sides
|
||||||
.scaleEffect(showContent ? 1.0 : 0.8)
|
.scaleEffect(showContent ? 1.0 : 0.8)
|
||||||
.opacity(showContent ? 1.0 : 0)
|
.opacity(showContent ? 1.0 : 0)
|
||||||
}
|
}
|
||||||
@ -201,11 +205,10 @@ struct ResultRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview("Single Hand") {
|
||||||
ResultBannerView(
|
ResultBannerView(
|
||||||
result: RoundResult(
|
result: RoundResult(
|
||||||
mainHandResult: .blackjack,
|
handResults: [.blackjack],
|
||||||
splitHandResult: nil,
|
|
||||||
insuranceResult: nil,
|
insuranceResult: nil,
|
||||||
totalWinnings: 150,
|
totalWinnings: 150,
|
||||||
wasBlackjack: true
|
wasBlackjack: true
|
||||||
@ -217,3 +220,18 @@ struct ResultRow: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview("Multiple Split Hands") {
|
||||||
|
ResultBannerView(
|
||||||
|
result: RoundResult(
|
||||||
|
handResults: [.bust, .win, .push],
|
||||||
|
insuranceResult: nil,
|
||||||
|
totalWinnings: 25,
|
||||||
|
wasBlackjack: false
|
||||||
|
),
|
||||||
|
currentBalance: 1025,
|
||||||
|
minBet: 10,
|
||||||
|
onNewRound: {},
|
||||||
|
onPlayAgain: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -50,7 +50,12 @@ public struct ChipSelectorView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
ScrollView(.horizontal) {
|
GeometryReader { geometry in
|
||||||
|
let chipCount = CGFloat(availableChips.count)
|
||||||
|
let totalChipsWidth = chipCount * CasinoDesign.Size.chipLarge + (chipCount - 1) * CasinoDesign.Spacing.medium
|
||||||
|
let fitsOnScreen = totalChipsWidth + CasinoDesign.Spacing.xLarge * 2 <= geometry.size.width
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: CasinoDesign.Spacing.medium) {
|
HStack(spacing: CasinoDesign.Spacing.medium) {
|
||||||
ForEach(availableChips) { denomination in
|
ForEach(availableChips) { denomination in
|
||||||
Button {
|
Button {
|
||||||
@ -68,11 +73,14 @@ public struct ChipSelectorView: View {
|
|||||||
.disabled(!canUseChip(denomination))
|
.disabled(!canUseChip(denomination))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, CasinoDesign.Spacing.large)
|
.padding(.horizontal, CasinoDesign.Spacing.xLarge)
|
||||||
.padding(.vertical, CasinoDesign.Spacing.medium) // Extra padding for selection scale effect
|
.padding(.vertical, CasinoDesign.Spacing.medium)
|
||||||
|
.frame(minWidth: geometry.size.width) // Center when content fits
|
||||||
}
|
}
|
||||||
.scrollIndicators(.hidden)
|
.scrollClipDisabled() // Prevent harsh clipping during scroll/animation
|
||||||
.frame(maxWidth: .infinity) // Center the scroll content
|
.scrollBounceBehavior(fitsOnScreen ? .basedOnSize : .automatic)
|
||||||
|
}
|
||||||
|
.frame(height: CasinoDesign.Size.chipLarge + CasinoDesign.Spacing.medium * 2)
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityLabel(String(localized: "Chip selector", bundle: .module))
|
.accessibilityLabel(String(localized: "Chip selector", bundle: .module))
|
||||||
.accessibilityHint(String(localized: "Double tap a chip to select bet amount", bundle: .module))
|
.accessibilityHint(String(localized: "Double tap a chip to select bet amount", bundle: .module))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user