From 982d54ed1d2bec5e285d8822f9c9d42691c48d56 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 29 Dec 2025 12:22:14 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Blackjack/Blackjack/Engine/GameState.swift | 14 +-- .../Blackjack/Resources/Localizable.xcstrings | 4 + .../Blackjack/Theme/DesignConstants.swift | 3 +- .../Views/Table/DealerHandView.swift | 53 ++++---- .../Blackjack/Views/Table/HandLabelView.swift | 107 ++++++++++++++++ .../Views/Table/PlayerHandView.swift | 115 ++++++++---------- 6 files changed, 197 insertions(+), 99 deletions(-) create mode 100644 Blackjack/Blackjack/Views/Table/HandLabelView.swift diff --git a/Blackjack/Blackjack/Engine/GameState.swift b/Blackjack/Blackjack/Engine/GameState.swift index ad3a1d5..254cbec 100644 --- a/Blackjack/Blackjack/Engine/GameState.swift +++ b/Blackjack/Blackjack/Engine/GameState.swift @@ -349,7 +349,7 @@ final class GameState { syncSoundSettings() loadSavedGame() - // Start timing for the first round's betting phase + // Start timing for the first round (includes betting phase) roundStartTime = Date() } @@ -503,9 +503,9 @@ final class GameState { func deal() async { guard canDeal else { return } - // Track bet amount for statistics (roundStartTime is set in newRound) + // Track bet amount for statistics (roundStartTime was set when betting phase started) roundBetAmount = currentBet + perfectPairsBet + twentyOnePlusThreeBet - Design.debugLog("🎴 Deal started - roundStartTime: \(String(describing: roundStartTime)), roundBetAmount: \(roundBetAmount)") + Design.debugLog("🎴 Deal started - roundBetAmount: \(roundBetAmount), time since round start: \(Date().timeIntervalSince(roundStartTime ?? Date()))s") // Ensure enough cards for a full hand - reshuffle if needed if !engine.canDealNewHand { @@ -1224,15 +1224,15 @@ final class GameState { twentyOnePlusThreeResult = nil showSideBetToasts = false - // Start timing for the new round (includes betting phase) - roundStartTime = Date() - Design.debugLog("🎰 New round started - roundStartTime: \(roundStartTime!)") - // Reset UI state showResultBanner = false lastRoundResult = nil currentPhase = .betting + // Start timing for the new round (includes betting phase) + roundStartTime = Date() + Design.debugLog("🎰 New round started - roundStartTime: \(roundStartTime!)") + sound.play(.newRound) } diff --git a/Blackjack/Blackjack/Resources/Localizable.xcstrings b/Blackjack/Blackjack/Resources/Localizable.xcstrings index 07dcaff..67bf7eb 100644 --- a/Blackjack/Blackjack/Resources/Localizable.xcstrings +++ b/Blackjack/Blackjack/Resources/Localizable.xcstrings @@ -5068,6 +5068,10 @@ } } }, + "PLAYER" : { + "comment" : "Title to display for a player hand when the hand number is not available.", + "isCommentAutoGenerated" : true + }, "Player hand: %@. Value: %@" : { "comment" : "A user-readable string describing a player's blackjack hand, including the card values and any relevant game results. The argument is a comma-separated list of the card descriptions in the player's hand.", "isCommentAutoGenerated" : true, diff --git a/Blackjack/Blackjack/Theme/DesignConstants.swift b/Blackjack/Blackjack/Theme/DesignConstants.swift index 3d99373..99c5b01 100644 --- a/Blackjack/Blackjack/Theme/DesignConstants.swift +++ b/Blackjack/Blackjack/Theme/DesignConstants.swift @@ -156,7 +156,8 @@ extension Color { enum Hand { static let player = Color(red: 0.2, green: 0.5, blue: 0.8) static let dealer = Color(red: 0.8, green: 0.3, blue: 0.3) - static let active = Color.yellow + /// Bright emerald green - readable with white text, indicates "your turn" + static let active = Color(red: 0.0, green: 0.7, blue: 0.45) static let inactive = Color.white.opacity(CasinoDesign.Opacity.medium) } diff --git a/Blackjack/Blackjack/Views/Table/DealerHandView.swift b/Blackjack/Blackjack/Views/Table/DealerHandView.swift index 0923913..a2b189d 100644 --- a/Blackjack/Blackjack/Views/Table/DealerHandView.swift +++ b/Blackjack/Blackjack/Views/Table/DealerHandView.swift @@ -2,7 +2,7 @@ // DealerHandView.swift // Blackjack // -// Displays the dealer's hand with cards and value. +// Displays the dealer's hand with label, value badge, and cards. // import SwiftUI @@ -21,37 +21,33 @@ struct DealerHandView: View { let visibleCardCount: Int @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize - @ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge + + /// The value to display in the badge (based on visible cards and hole card state). + private var displayValue: Int? { + guard !hand.cards.isEmpty && visibleCardCount > 0 else { return nil } + + if showHoleCard { + // Hole card revealed - calculate value from visible cards + let visibleCards = Array(hand.cards.prefix(visibleCardCount)) + return BlackjackHand.bestValue(for: visibleCards) + } else { + // Hole card hidden - show only the first (face-up) card's value + return hand.cards[0].blackjackValue + } + } var body: some View { - VStack(spacing: Design.Spacing.small) { - // Label and value - fixed height prevents vertical layout shift - HStack(spacing: Design.Spacing.small) { - Text(String(localized: "DEALER")) - .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - - // Calculate value from visible cards only - if !hand.cards.isEmpty && visibleCardCount > 0 { - if showHoleCard { - // Hole card revealed - calculate value from visible cards - let visibleCards = Array(hand.cards.prefix(visibleCardCount)) - let displayValue = BlackjackHand.bestValue(for: visibleCards) - ValueBadge(value: displayValue, color: Color.Hand.dealer) - .animation(nil, value: displayValue) // No animation when value changes - } else { - // Hole card hidden - show only the first (face-up) card's value - ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer) - .animation(nil, value: hand.cards[0].blackjackValue) // No animation when value changes - } - } - } - .frame(minHeight: badgeHeight) // Reserve consistent height - // Remove animations on badge appearance/value changes + // Label and value badge + HandLabelView( + title: String(localized: "DEALER"), + value: displayValue, + badgeColor: Color.Hand.dealer + ) .animation(nil, value: visibleCardCount) .animation(nil, value: showHoleCard) - // Cards with result badge overlay (overlay prevents height change) + + // Cards with result badge overlay HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) { CardStackView.dealer( cards: hand.cards, @@ -118,6 +114,8 @@ struct DealerHandView: View { } } +// MARK: - Previews + #Preview("Empty Hand") { ZStack { Color.Table.felt.ignoresSafeArea() @@ -171,4 +169,3 @@ struct DealerHandView: View { ) } } - diff --git a/Blackjack/Blackjack/Views/Table/HandLabelView.swift b/Blackjack/Blackjack/Views/Table/HandLabelView.swift new file mode 100644 index 0000000..c536046 --- /dev/null +++ b/Blackjack/Blackjack/Views/Table/HandLabelView.swift @@ -0,0 +1,107 @@ +// +// HandLabelView.swift +// Blackjack +// +// Shared label component for hand displays (dealer and player). +// Shows title text with an optional value badge. +// + +import SwiftUI +import CasinoKit + +/// Displays a hand label with title and value badge. +/// Used by both DealerHandView and PlayerHandView for consistent appearance. +struct HandLabelView: View { + let title: String + let value: Int? + let valueText: String? + let badgeColor: Color + + @ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize + @ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge + + /// Creates a hand label with a numeric value badge. + init(title: String, value: Int?, badgeColor: Color) { + self.title = title + self.value = value + self.valueText = nil + self.badgeColor = badgeColor + } + + /// Creates a hand label with a text value badge (for soft hands like "8/18"). + init(title: String, valueText: String?, badgeColor: Color) { + self.title = title + self.value = nil + self.valueText = valueText + self.badgeColor = badgeColor + } + + var body: some View { + HStack(spacing: Design.Spacing.small) { + Text(title) + .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + if let value = value { + ValueBadge(value: value, color: badgeColor) + } else if let text = valueText, !text.isEmpty { + TextValueBadge(text: text, color: badgeColor) + } + } + .fixedSize() // Prevent the label from being constrained/truncated + .frame(minHeight: badgeHeight) + .animation(nil, value: value) + .animation(nil, value: valueText) + } +} + +/// A badge that displays text (for soft hand values like "8/18"). +struct TextValueBadge: View { + let text: String + let color: Color + + // Match ValueBadge styling from CasinoKit + @ScaledMetric(relativeTo: .headline) private var fontSize: CGFloat = 15 + @ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge + @ScaledMetric(relativeTo: .headline) private var badgePadding: CGFloat = 8 + + var body: some View { + Text(text) + .font(.system(size: fontSize, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) // Prevent truncation + .padding(.horizontal, badgePadding) + .frame(minHeight: badgeHeight) + .background( + Capsule() + .fill(color) + ) + } +} + +// MARK: - Previews + +#Preview("Dealer Label") { + ZStack { + Color.Table.felt.ignoresSafeArea() + VStack(spacing: Design.Spacing.large) { + HandLabelView(title: "DEALER", value: 21, badgeColor: Color.Hand.dealer) + HandLabelView(title: "DEALER", value: 17, badgeColor: Color.Hand.dealer) + HandLabelView(title: "DEALER", value: nil, badgeColor: Color.Hand.dealer) + } + } +} + +#Preview("Player Label") { + ZStack { + Color.Table.felt.ignoresSafeArea() + VStack(spacing: Design.Spacing.large) { + HandLabelView(title: "PLAYER", value: 21, badgeColor: Color.Hand.player) + HandLabelView(title: "PLAYER", valueText: "8/18", badgeColor: Color.Hand.player) + HandLabelView(title: "Hand 1", value: 17, badgeColor: Color.Hand.player) + HandLabelView(title: "Hand 2", valueText: "5/15", badgeColor: Color.Hand.active) + } + } +} + diff --git a/Blackjack/Blackjack/Views/Table/PlayerHandView.swift b/Blackjack/Blackjack/Views/Table/PlayerHandView.swift index 2aa39cc..115f0cf 100644 --- a/Blackjack/Blackjack/Views/Table/PlayerHandView.swift +++ b/Blackjack/Blackjack/Views/Table/PlayerHandView.swift @@ -2,7 +2,7 @@ // PlayerHandView.swift // Blackjack // -// Displays a single player hand with cards, value, bet, and result. +// Displays a single player hand with label, value badge, cards, and bet. // import SwiftUI @@ -34,8 +34,21 @@ struct PlayerHandView: View { @ScaledMetric(relativeTo: .body) private var hintPaddingH: CGFloat = Design.Size.hintPaddingH @ScaledMetric(relativeTo: .body) private var hintPaddingV: CGFloat = Design.Size.hintPaddingV + /// The title to display (PLAYER or Hand #). + private var displayTitle: String { + if let number = handNumber { + return String(localized: "Hand \(number)") + } + return String(localized: "PLAYER") + } + + /// The badge color (active hands get highlight color). + private var badgeColor: Color { + isActive ? Color.Hand.active : Color.Hand.player + } + /// Calculates display info for visible cards using shared BlackjackHand logic. - private var visibleCardsDisplayInfo: (text: String, color: Color)? { + private var visibleCardsDisplayInfo: (text: String, value: Int, isBusted: Bool)? { guard !hand.cards.isEmpty else { return nil } let visibleCards = Array(hand.cards.prefix(visibleCardCount)) @@ -45,27 +58,24 @@ struct PlayerHandView: View { let (hardValue, softValue) = BlackjackHand.calculateValues(for: visibleCards) let displayValue = BlackjackHand.bestValue(for: visibleCards) let hasSoftAce = BlackjackHand.hasSoftAce(for: visibleCards) - - // Determine color based on visible cards - let isVisibleBlackjack = visibleCards.count == 2 && displayValue == 21 && !hand.isSplit - let isVisibleBusted = hardValue > 21 - let isVisible21 = displayValue == 21 && !isVisibleBlackjack - - let displayColor: Color = { - if isVisibleBlackjack { return .yellow } - if isVisibleBusted { return .red } - if isVisible21 { return .green } - return .white - }() + let isBusted = hardValue > 21 // Show value like hand.valueDisplay does (e.g., "8/18" for soft hands) let valueText = hasSoftAce ? "\(hardValue)/\(softValue)" : "\(displayValue)" - return (valueText, displayColor) + return (valueText, displayValue, isBusted) } var body: some View { VStack(spacing: Design.Spacing.small) { + // Label and value badge at TOP (consistent with dealer) + HandLabelView( + title: displayTitle, + valueText: visibleCardsDisplayInfo?.text, + badgeColor: badgeColor + ) + .animation(nil, value: visibleCardCount) + // Cards with container CardStackView.player( cards: hand.cards, @@ -112,39 +122,25 @@ struct PlayerHandView: View { .animation(.easeInOut(duration: Design.Animation.quick), value: isActive) .animation(.spring(duration: Design.Animation.springDuration), value: hand.result != nil) - // 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)) - } - - // Calculate value from visible (animation-completed) cards only - if let displayInfo = visibleCardsDisplayInfo { - Text(displayInfo.text) - .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) - .foregroundStyle(displayInfo.color) - .animation(nil, value: displayInfo.text) - .animation(nil, value: displayInfo.color) - } - - if hand.isDoubledDown { - Image(systemName: "xmark.circle.fill") - .font(.system(size: iconSize)) - .foregroundStyle(.purple) - } - } - - // Bet amount - if hand.bet > 0 { - HStack(spacing: Design.Spacing.xSmall) { - Image(systemName: "dollarsign.circle.fill") - .font(.system(size: iconSize)) - .foregroundStyle(.yellow) - Text("\(hand.bet * (hand.isDoubledDown ? 2 : 1))") - .font(.system(size: handNumberSize, weight: .bold, design: .rounded)) - .foregroundStyle(.yellow) + // Bet amount and doubled down indicator + if hand.bet > 0 || hand.isDoubledDown { + HStack(spacing: Design.Spacing.small) { + if hand.bet > 0 { + HStack(spacing: Design.Spacing.xSmall) { + Image(systemName: "dollarsign.circle.fill") + .font(.system(size: iconSize)) + .foregroundStyle(.yellow) + Text("\(hand.bet * (hand.isDoubledDown ? 2 : 1))") + .font(.system(size: handNumberSize, weight: .bold, design: .rounded)) + .foregroundStyle(.yellow) + } + } + + if hand.isDoubledDown { + Image(systemName: "xmark.circle.fill") + .font(.system(size: iconSize)) + .foregroundStyle(.purple) + } } } } @@ -154,13 +150,6 @@ struct PlayerHandView: View { // 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)") @@ -192,7 +181,7 @@ struct PlayerHandView: View { } } -#Preview("With Cards") { +#Preview("With Cards - Active") { ZStack { Color.Table.felt.ignoresSafeArea() PlayerHandView( @@ -214,15 +203,15 @@ struct PlayerHandView: View { } } -#Preview("Blackjack") { +#Preview("Soft Hand") { ZStack { Color.Table.felt.ignoresSafeArea() PlayerHandView( hand: BlackjackHand(cards: [ Card(suit: .hearts, rank: .ace), - Card(suit: .spades, rank: .king) + Card(suit: .spades, rank: .seven) ], bet: 100), - isActive: false, + isActive: true, showCardCount: true, showAnimations: true, dealingSpeed: 1.0, @@ -236,7 +225,7 @@ struct PlayerHandView: View { } } -#Preview("Split Hand") { +#Preview("Split Hand - Inactive") { ZStack { Color.Table.felt.ignoresSafeArea() PlayerHandView( @@ -244,7 +233,7 @@ struct PlayerHandView: View { Card(suit: .clubs, rank: .eight), Card(suit: .spades, rank: .jack) ], bet: 100), - isActive: true, + isActive: false, showCardCount: false, showAnimations: true, dealingSpeed: 1.0, @@ -252,8 +241,8 @@ struct PlayerHandView: View { cardWidth: 60, cardSpacing: -20, visibleCardCount: 2, - currentHint: "Stand", - showHintToast: true + currentHint: nil, + showHintToast: false ) } }