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

This commit is contained in:
Matt Bruce 2025-12-22 15:11:18 -06:00
parent a0ac5a6e64
commit 7e16e67826
8 changed files with 193 additions and 46 deletions

View File

@ -411,7 +411,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
LD_RUNPATH_SEARCH_PATHS = (
@ -443,7 +443,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
LD_RUNPATH_SEARCH_PATHS = (

View File

@ -403,96 +403,99 @@ final class BlackjackEngine {
let dealerValue = dealerUpCard.blackjackValue
let isSoft = playerHand.isSoft
// Check for count-based deviations from basic strategy
// Helper to format true count with sign
let tcDisplay = tc >= 0 ? "+\(tc)" : "\(tc)"
// 16 vs 10: Stand at TC 0+ (basic says Hit)
// Check for count-based deviations from basic strategy (Illustrious 18)
// 16 vs 10: Stand when TC 0 (basic strategy says Hit)
if playerValue == 16 && !isSoft && dealerValue == 10 {
if tc >= 0 {
return String(localized: "Stand (Count: 16v10 at TC≥0)")
return String(localized: "Stand instead of Hit (TC \(tcDisplay), deck is neutral/rich)")
}
}
// 15 vs 10: Stand at TC +4+ (basic says Hit)
// 15 vs 10: Stand when TC +4 (basic strategy says Hit/Surrender)
if playerValue == 15 && !isSoft && dealerValue == 10 {
if tc >= 4 {
return String(localized: "Stand (Count: 15v10 at TC≥+4)")
return String(localized: "Stand instead of Hit (TC \(tcDisplay), deck is very rich)")
}
}
// 12 vs 2: Stand at TC +3+ (basic says Hit)
// 12 vs 2: Stand when TC +3 (basic strategy says Hit)
if playerValue == 12 && !isSoft && dealerValue == 2 {
if tc >= 3 {
return String(localized: "Stand (Count: 12v2 at TC≥+3)")
return String(localized: "Stand instead of Hit (TC \(tcDisplay), dealer likely to bust)")
}
}
// 12 vs 3: Stand at TC +2+ (basic says Hit)
// 12 vs 3: Stand when TC +2 (basic strategy says Hit)
if playerValue == 12 && !isSoft && dealerValue == 3 {
if tc >= 2 {
return String(localized: "Stand (Count: 12v3 at TC≥+2)")
return String(localized: "Stand instead of Hit (TC \(tcDisplay), dealer likely to bust)")
}
}
// 12 vs 4: Hit at TC < 0 (basic says Stand)
// 12 vs 4: Hit when TC < 0 (basic strategy says Stand)
if playerValue == 12 && !isSoft && dealerValue == 4 {
if tc < 0 {
return String(localized: "Hit (Count: 12v4 at TC<0)")
return String(localized: "Hit instead of Stand (TC \(tcDisplay), deck is poor)")
}
}
// 13 vs 2: Hit at TC < -1 (basic says Stand)
// 13 vs 2: Hit when TC < -1 (basic strategy says Stand)
if playerValue == 13 && !isSoft && dealerValue == 2 {
if tc < -1 {
return String(localized: "Hit (Count: 13v2 at TC<-1)")
return String(localized: "Hit instead of Stand (TC \(tcDisplay), deck is very poor)")
}
}
// 16 vs 9: Stand at TC +5+ (basic says Hit)
// 16 vs 9: Stand when TC +5 (basic strategy says Hit)
if playerValue == 16 && !isSoft && dealerValue == 9 {
if tc >= 5 {
return String(localized: "Stand (Count: 16v9 at TC≥+5)")
return String(localized: "Stand instead of Hit (TC \(tcDisplay), deck is extremely rich)")
}
}
// 10 vs 10: Double at TC +4+ (basic says Hit)
// 10 vs 10: Double when TC +4 (basic strategy says Hit)
if playerValue == 10 && !isSoft && playerHand.cards.count == 2 && dealerValue == 10 {
if tc >= 4 {
return String(localized: "Double (Count: 10v10 at TC≥+4)")
return String(localized: "Double instead of Hit (TC \(tcDisplay), high cards favor you)")
}
}
// 10 vs A: Double at TC +4+ (basic says Hit)
// 10 vs Ace: Double when TC +4 (basic strategy says Hit)
if playerValue == 10 && !isSoft && playerHand.cards.count == 2 && dealerValue == 1 {
if tc >= 4 {
return String(localized: "Double (Count: 10vA at TC≥+4)")
return String(localized: "Double instead of Hit (TC \(tcDisplay), high cards favor you)")
}
}
// 9 vs 2: Double at TC +1+ (basic says Hit)
// 9 vs 2: Double when TC +1 (basic strategy says Hit)
if playerValue == 9 && !isSoft && playerHand.cards.count == 2 && dealerValue == 2 {
if tc >= 1 {
return String(localized: "Double (Count: 9v2 at TC≥+1)")
return String(localized: "Double instead of Hit (TC \(tcDisplay), slight edge to double)")
}
}
// 9 vs 7: Double at TC +3+ (basic says Hit)
// 9 vs 7: Double when TC +3 (basic strategy says Hit)
if playerValue == 9 && !isSoft && playerHand.cards.count == 2 && dealerValue == 7 {
if tc >= 3 {
return String(localized: "Double (Count: 9v7 at TC≥+3)")
return String(localized: "Double instead of Hit (TC \(tcDisplay), deck favors doubling)")
}
}
// Pair of 10s vs 5: Split at TC +5+ (basic says Stand)
// Pair of 10s vs 5: Split when TC +5 (basic strategy says Stand)
if playerHand.canSplit && playerHand.cards[0].blackjackValue == 10 && dealerValue == 5 {
if tc >= 5 {
return String(localized: "Split (Count: 10,10v5 at TC≥+5)")
return String(localized: "Split instead of Stand (TC \(tcDisplay), dealer very likely to bust)")
}
}
// Pair of 10s vs 6: Split at TC +4+ (basic says Stand)
// Pair of 10s vs 6: Split when TC +4 (basic strategy says Stand)
if playerHand.canSplit && playerHand.cards[0].blackjackValue == 10 && dealerValue == 6 {
if tc >= 4 {
return String(localized: "Split (Count: 10,10v6 at TC≥+4)")
return String(localized: "Split instead of Stand (TC \(tcDisplay), dealer very likely to bust)")
}
}

View File

@ -661,6 +661,7 @@ final class GameState {
var roundWinnings = 0
var wasBlackjack = false
var hadBust = false
var perHandWinnings: [Int] = []
// Evaluate each hand
for i in 0..<playerHands.count {
@ -677,8 +678,12 @@ final class GameState {
result: result,
isDoubled: playerHands[i].isDoubledDown
)
let totalBet = playerHands[i].bet * (playerHands[i].isDoubledDown ? 2 : 1)
let handWinnings = payout - totalBet
balance += payout
roundWinnings += payout - playerHands[i].bet * (playerHands[i].isDoubledDown ? 2 : 1)
roundWinnings += handWinnings
perHandWinnings.append(handWinnings)
if result == .blackjack {
wasBlackjack = true
@ -686,6 +691,24 @@ final class GameState {
if result == .bust {
hadBust = true
}
} else {
perHandWinnings.append(0)
}
}
// Calculate insurance result
var insResult: HandResult? = nil
var insWinnings = 0
if insuranceBet > 0 {
if dealerHand.isBlackjack {
insResult = .insuranceWin
insWinnings = insuranceBet * 2 // Insurance pays 2:1
balance += insuranceBet * 3 // Return bet + 2x winnings
roundWinnings += insWinnings
} else {
insResult = .insuranceLose
insWinnings = -insuranceBet // Lost insurance bet
roundWinnings += insWinnings
}
}
@ -704,11 +727,13 @@ final class GameState {
bustCount += 1
}
// Create round result with all hand results
// Create round result with all hand results and per-hand winnings
let allHandResults = playerHands.map { $0.result ?? .lose }
lastRoundResult = RoundResult(
handResults: allHandResults,
insuranceResult: insuranceBet > 0 ? (dealerHand.isBlackjack ? .insuranceWin : .insuranceLose) : nil,
handWinnings: perHandWinnings,
insuranceResult: insResult,
insuranceWinnings: insWinnings,
totalWinnings: roundWinnings,
wasBlackjack: wasBlackjack
)

View File

@ -68,10 +68,34 @@ enum HandResult: Equatable {
struct RoundResult: Equatable {
/// Results for all player hands (index 0 = Hand 1, index 1 = Hand 2, etc.)
let handResults: [HandResult]
/// Net winnings for each hand (parallel to handResults)
let handWinnings: [Int]
let insuranceResult: HandResult?
/// Insurance winnings (positive if won, negative if lost)
let insuranceWinnings: Int
let totalWinnings: Int
let wasBlackjack: Bool
/// Convenience initializer without per-hand winnings (backwards compatibility)
init(handResults: [HandResult], insuranceResult: HandResult?, totalWinnings: Int, wasBlackjack: Bool) {
self.handResults = handResults
self.handWinnings = [] // Empty means don't show per-hand amounts
self.insuranceResult = insuranceResult
self.insuranceWinnings = 0
self.totalWinnings = totalWinnings
self.wasBlackjack = wasBlackjack
}
/// Full initializer with per-hand winnings
init(handResults: [HandResult], handWinnings: [Int], insuranceResult: HandResult?, insuranceWinnings: Int, totalWinnings: Int, wasBlackjack: Bool) {
self.handResults = handResults
self.handWinnings = handWinnings
self.insuranceResult = insuranceResult
self.insuranceWinnings = insuranceWinnings
self.totalWinnings = totalWinnings
self.wasBlackjack = wasBlackjack
}
/// The main/best result for display purposes (first hand, or best if split)
var mainHandResult: HandResult {
// Return the best result for the headline

View File

@ -36,12 +36,16 @@ enum Design {
enum Size {
// Hand scaling factor (1.5 = 50% larger hands)
static let handScale: CGFloat = 1.5
static let handScale: CGFloat = 1.75
// Cards - scaled for better visibility
static let cardWidth: CGFloat = 60 * handScale // 90pt at 1.5x
static let cardWidthSmall: CGFloat = CasinoDesign.Size.cardWidthSmall
static let cardOverlap: CGFloat = CasinoDesign.Size.cardOverlap * handScale // Scaled overlap
/// Card overlap (negative = cards stack left over right).
/// More negative = more overlap (less card visible).
/// With 90pt cards: -40 = ~44% overlap, -50 = ~55% overlap
static let cardOverlap: CGFloat = -50
// Player hands container height (accommodates larger cards + labels)
// Reduced from 180 to fit content more snugly

View File

@ -72,10 +72,19 @@ struct GameTableView: View {
@ViewBuilder
private func mainGameView(state: GameState) -> some View {
GeometryReader { geometry in
ZStack {
// Background
TableBackgroundView()
mainContent(state: state, screenHeight: geometry.size.height)
}
}
}
@ViewBuilder
private func mainContent(state: GameState, screenHeight: CGFloat) -> some View {
ZStack {
VStack(spacing: 0) {
// Top bar
TopBarView(
@ -110,7 +119,8 @@ struct GameTableView: View {
// Table layout - fills available space
BlackjackTableView(
state: state,
onPlaceBet: { placeBet(state: state) }
onPlaceBet: { placeBet(state: state) },
screenHeight: screenHeight
)
.frame(maxWidth: maxContentWidth)

View File

@ -68,19 +68,31 @@ struct ResultBannerView: View {
.font(.system(size: amountFontSize, weight: .bold, design: .rounded))
.foregroundStyle(winningsColor)
// Breakdown - all hands
// Breakdown - all hands with amounts for splits
VStack(spacing: Design.Spacing.small) {
ForEach(result.handResults.indices, id: \.self) { index in
let handResult = result.handResults[index]
let handWinnings = index < result.handWinnings.count ? result.handWinnings[index] : nil
// Hand numbering: index 0 = Hand 1 (played first, displayed rightmost)
let handLabel = result.handResults.count > 1
? String(localized: "Hand \(index + 1)")
: String(localized: "Main Hand")
ResultRow(label: handLabel, result: handResult)
// Show amounts for split hands, or for single hand if there are winnings
let showAmount = result.hadSplit && handWinnings != nil
ResultRow(
label: handLabel,
result: handResult,
amount: showAmount ? handWinnings : nil
)
}
if let insuranceResult = result.insuranceResult {
ResultRow(label: String(localized: "Insurance"), result: insuranceResult)
let showInsAmount = result.insuranceWinnings != 0
ResultRow(
label: String(localized: "Insurance"),
result: insuranceResult,
amount: showInsAmount ? result.insuranceWinnings : nil
)
}
}
.padding(Design.Spacing.medium)
@ -189,6 +201,25 @@ struct ResultBannerView: View {
struct ResultRow: View {
let label: String
let result: HandResult
var amount: Int? = nil
private var amountText: String? {
guard let amount = amount else { return nil }
if amount > 0 {
return "+$\(amount)"
} else if amount < 0 {
return "-$\(abs(amount))"
} else {
return "$0"
}
}
private var amountColor: Color {
guard let amount = amount else { return .white }
if amount > 0 { return .green }
if amount < 0 { return .red }
return .blue
}
var body: some View {
HStack {
@ -198,9 +229,18 @@ struct ResultRow: View {
Spacer()
// Show amount if provided
if let amountText = amountText {
Text(amountText)
.font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded))
.foregroundStyle(amountColor)
.frame(width: 70, alignment: .trailing)
}
Text(result.displayText)
.font(.system(size: Design.BaseFontSize.body, weight: .bold))
.foregroundStyle(result.color)
.frame(width: 100, alignment: .trailing)
}
}
}
@ -209,7 +249,9 @@ struct ResultRow: View {
ResultBannerView(
result: RoundResult(
handResults: [.blackjack],
handWinnings: [150],
insuranceResult: nil,
insuranceWinnings: 0,
totalWinnings: 150,
wasBlackjack: true
),
@ -224,11 +266,30 @@ struct ResultRow: View {
ResultBannerView(
result: RoundResult(
handResults: [.bust, .win, .push],
handWinnings: [-100, 100, 0],
insuranceResult: nil,
totalWinnings: 25,
insuranceWinnings: 0,
totalWinnings: 0,
wasBlackjack: false
),
currentBalance: 1025,
currentBalance: 1000,
minBet: 10,
onNewRound: {},
onPlayAgain: {}
)
}
#Preview("Split with Insurance") {
ResultBannerView(
result: RoundResult(
handResults: [.lose, .win],
handWinnings: [-100, 200],
insuranceResult: .insuranceWin,
insuranceWinnings: 100,
totalWinnings: 200,
wasBlackjack: false
),
currentBalance: 1200,
minBet: 10,
onNewRound: {},
onPlayAgain: {}

View File

@ -12,6 +12,9 @@ struct BlackjackTableView: View {
@Bindable var state: GameState
let onPlaceBet: () -> Void
/// Screen height passed from parent for responsive sizing
var screenHeight: CGFloat = 800
/// Whether to show Hi-Lo card count values on cards.
var showCardCount: Bool { state.settings.showCardCount }
@ -32,6 +35,23 @@ struct BlackjackTableView: View {
// Use global debug flag from Design constants
private var showDebugBorders: Bool { Design.showDebugBorders }
/// Dynamic spacer height based on screen size.
/// Formula: spacing = clamp((screenHeight - baseline) * scale, min, max)
/// This produces smooth scaling across all device sizes:
/// - iPhone SE (~667pt): ~20pt
/// - iPhone Pro Max (~932pt): ~76pt
/// - iPad Mini (~1024pt): ~95pt
/// - iPad Pro 12.9" (~1366pt): ~150pt (capped)
private var dealerPlayerSpacing: CGFloat {
let baseline: CGFloat = 550 // Below this, use minimum
let scale: CGFloat = 0.2 // 20% of height above baseline
let minSpacing: CGFloat = 20 // Floor for smallest screens
let maxSpacing: CGFloat = 150 // Ceiling for largest screens
let calculated = (screenHeight - baseline) * scale
return min(maxSpacing, max(minSpacing, calculated))
}
var body: some View {
VStack(spacing: Design.Spacing.small) {
// Dealer area
@ -44,9 +64,9 @@ struct BlackjackTableView: View {
)
.debugBorder(showDebugBorders, color: .red, label: "Dealer")
// Flexible space between dealer and player (minimum 60pt)
Spacer(minLength: 60)
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer")
// Flexible space between dealer and player - scales with screen size
Spacer(minLength: dealerPlayerSpacing)
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer \(Int(dealerPlayerSpacing))")
// Player hands area - only show when there are cards dealt
if state.playerHands.first?.cards.isEmpty == false {