Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-29 12:22:14 -06:00
parent a9b4f95bb4
commit 982d54ed1d
6 changed files with 197 additions and 99 deletions

View File

@ -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)
}

View File

@ -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,

View File

@ -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)
}

View File

@ -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 {
)
}
}

View File

@ -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)
}
}
}

View File

@ -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
)
}
}