Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
50f569d137
commit
ec484d8574
108
Blackjack/Views/Game/ActionButton.swift
Normal file
108
Blackjack/Views/Game/ActionButton.swift
Normal file
@ -0,0 +1,108 @@
|
||||
//
|
||||
// ActionButton.swift
|
||||
// Blackjack
|
||||
//
|
||||
// Reusable styled button for game actions.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
struct ActionButton: View {
|
||||
let title: String
|
||||
let icon: String?
|
||||
let style: ButtonStyle
|
||||
let action: () -> Void
|
||||
|
||||
enum ButtonStyle {
|
||||
case primary // Gold gradient (Deal, New Round)
|
||||
case destructive // Red (Clear)
|
||||
case secondary // Subtle white
|
||||
case custom(Color) // Game-specific colors (Hit, Stand, etc.)
|
||||
|
||||
var foregroundColor: Color {
|
||||
switch self {
|
||||
case .primary: return .black
|
||||
case .destructive, .secondary, .custom: return .white
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(_ title: String, icon: String? = nil, style: ButtonStyle = .primary, action: @escaping () -> Void) {
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
self.style = style
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
if let icon = icon {
|
||||
Image(systemName: icon)
|
||||
}
|
||||
Text(title)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(CasinoDesign.MinScaleFactor.relaxed)
|
||||
}
|
||||
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
|
||||
.foregroundStyle(style.foregroundColor)
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.background(backgroundView)
|
||||
}
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var backgroundView: some View {
|
||||
switch style {
|
||||
case .primary:
|
||||
Capsule()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.Button.goldLight, Color.Button.goldDark],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
case .destructive:
|
||||
Capsule()
|
||||
.fill(Color.red.opacity(Design.Opacity.heavy))
|
||||
case .secondary:
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(Design.Opacity.hint))
|
||||
case .custom(let color):
|
||||
Capsule()
|
||||
.fill(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Primary") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
ActionButton("Deal", icon: "play.fill", style: .primary) {}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Destructive") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
ActionButton("Clear", icon: "xmark.circle", style: .destructive) {}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Custom Colors") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
ActionButton("Hit", style: .custom(Color.Button.hit)) {}
|
||||
ActionButton("Stand", style: .custom(Color.Button.stand)) {}
|
||||
ActionButton("Double", style: .custom(Color.Button.doubleDown)) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
314
Blackjack/Views/Game/ActionButtonsView.swift
Normal file
314
Blackjack/Views/Game/ActionButtonsView.swift
Normal file
@ -0,0 +1,314 @@
|
||||
//
|
||||
// ActionButtonsView.swift
|
||||
// Blackjack
|
||||
//
|
||||
// Container for game action buttons (betting, player turn).
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
struct ActionButtonsView: View {
|
||||
@Bindable var state: GameState
|
||||
|
||||
// Scaled metrics
|
||||
@ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = Design.BaseFontSize.large
|
||||
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.IconSize.large
|
||||
|
||||
// Fixed height to prevent layout shifts
|
||||
private let containerHeight: CGFloat = 120
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.clear
|
||||
.frame(height: containerHeight)
|
||||
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
// Primary actions
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
switch state.currentPhase {
|
||||
case .betting:
|
||||
bettingButtons
|
||||
case .playerTurn:
|
||||
playerTurnButtons
|
||||
case .roundComplete:
|
||||
// Empty - handled by result banner
|
||||
EmptyView()
|
||||
default:
|
||||
// Dealing, dealer turn - show nothing
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.animation(.spring(duration: Design.Animation.quick), value: state.currentPhase)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
}
|
||||
|
||||
// MARK: - Betting Phase Buttons
|
||||
|
||||
/// Whether the current bet meets the minimum requirement
|
||||
private var isBetBelowMinimum: Bool {
|
||||
state.currentBet > 0 && state.currentBet < state.settings.minBet
|
||||
}
|
||||
|
||||
/// Amount needed to reach minimum bet
|
||||
private var amountNeededForMinimum: Int {
|
||||
state.settings.minBet - state.currentBet
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var bettingButtons: some View {
|
||||
if state.currentBet > 0 {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
// Show hint if bet is below minimum
|
||||
if isBetBelowMinimum {
|
||||
Text(String(localized: "Add $\(amountNeededForMinimum) more to meet minimum"))
|
||||
.font(.system(size: Design.BaseFontSize.small, weight: .medium))
|
||||
.foregroundStyle(.orange)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
ActionButton(
|
||||
String(localized: "Clear"),
|
||||
icon: "xmark.circle",
|
||||
style: .destructive
|
||||
) {
|
||||
state.clearBet()
|
||||
}
|
||||
|
||||
// Always show Deal button, but disable if below minimum
|
||||
ActionButton(
|
||||
String(localized: "Deal"),
|
||||
icon: "play.fill",
|
||||
style: .primary
|
||||
) {
|
||||
Task { await state.deal() }
|
||||
}
|
||||
.opacity(state.canDeal ? 1.0 : Design.Opacity.medium)
|
||||
.disabled(!state.canDeal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player Turn Buttons
|
||||
|
||||
@ViewBuilder
|
||||
private var playerTurnButtons: some View {
|
||||
// All player actions in a single row
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
if state.canHit {
|
||||
ActionButton(
|
||||
String(localized: "Hit"),
|
||||
style: .custom(Color.Button.hit)
|
||||
) {
|
||||
Task { await state.hit() }
|
||||
}
|
||||
}
|
||||
|
||||
if state.canStand {
|
||||
ActionButton(
|
||||
String(localized: "Stand"),
|
||||
style: .custom(Color.Button.stand)
|
||||
) {
|
||||
Task { await state.stand() }
|
||||
}
|
||||
}
|
||||
|
||||
if state.canDouble {
|
||||
ActionButton(
|
||||
String(localized: "Double"),
|
||||
style: .custom(Color.Button.doubleDown)
|
||||
) {
|
||||
Task { await state.doubleDown() }
|
||||
}
|
||||
}
|
||||
|
||||
if state.canSplit {
|
||||
ActionButton(
|
||||
String(localized: "Split"),
|
||||
style: .custom(Color.Button.split)
|
||||
) {
|
||||
Task { await state.split() }
|
||||
}
|
||||
}
|
||||
|
||||
if state.canSurrender {
|
||||
ActionButton(
|
||||
String(localized: "Surrender"),
|
||||
style: .custom(Color.Button.surrender)
|
||||
) {
|
||||
Task { await state.surrender() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Betting Phase - No Bet") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
ActionButtonsView(state: {
|
||||
let state = GameState(settings: GameSettings())
|
||||
return state
|
||||
}())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Betting Phase - With Bet") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
ActionButtonsView(state: {
|
||||
let state = GameState(settings: GameSettings())
|
||||
state.placeBet(amount: 100)
|
||||
return state
|
||||
}())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Betting Phase - Below Minimum") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
ActionButtonsView(state: {
|
||||
let state = GameState(settings: GameSettings())
|
||||
state.placeBet(amount: 25)
|
||||
return state
|
||||
}())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player Turn Button Previews
|
||||
|
||||
/// Preview helper that shows player turn button layouts without needing real game state.
|
||||
private struct PlayerTurnButtonsPreview: View {
|
||||
let showHit: Bool
|
||||
let showStand: Bool
|
||||
let showDouble: Bool
|
||||
let showSplit: Bool
|
||||
let showSurrender: Bool
|
||||
|
||||
private let containerHeight: CGFloat = 120
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.clear
|
||||
.frame(height: containerHeight)
|
||||
|
||||
// Single row of buttons matching actual game layout
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
if showHit {
|
||||
ActionButton("Hit", style: .custom(Color.Button.hit)) {}
|
||||
}
|
||||
if showStand {
|
||||
ActionButton("Stand", style: .custom(Color.Button.stand)) {}
|
||||
}
|
||||
if showDouble {
|
||||
ActionButton("Double", style: .custom(Color.Button.doubleDown)) {}
|
||||
}
|
||||
if showSplit {
|
||||
ActionButton("Split", style: .custom(Color.Button.split)) {}
|
||||
}
|
||||
if showSurrender {
|
||||
ActionButton("Surrender", style: .custom(Color.Button.surrender)) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Player Turn - Hit & Stand Only") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
PlayerTurnButtonsPreview(
|
||||
showHit: true,
|
||||
showStand: true,
|
||||
showDouble: false,
|
||||
showSplit: false,
|
||||
showSurrender: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Player Turn - With Double") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
PlayerTurnButtonsPreview(
|
||||
showHit: true,
|
||||
showStand: true,
|
||||
showDouble: true,
|
||||
showSplit: false,
|
||||
showSurrender: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Player Turn - With Split") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
PlayerTurnButtonsPreview(
|
||||
showHit: true,
|
||||
showStand: true,
|
||||
showDouble: true,
|
||||
showSplit: true,
|
||||
showSurrender: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Player Turn - With Surrender") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
PlayerTurnButtonsPreview(
|
||||
showHit: true,
|
||||
showStand: true,
|
||||
showDouble: true,
|
||||
showSplit: false,
|
||||
showSurrender: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Player Turn - All Options") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
PlayerTurnButtonsPreview(
|
||||
showHit: true,
|
||||
showStand: true,
|
||||
showDouble: true,
|
||||
showSplit: true,
|
||||
showSurrender: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Player Turn - After Hit (No Double/Split)") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
PlayerTurnButtonsPreview(
|
||||
showHit: true,
|
||||
showStand: true,
|
||||
showDouble: false,
|
||||
showSplit: false,
|
||||
showSurrender: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Player Turn - Split Hand (No Resplit)") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
PlayerTurnButtonsPreview(
|
||||
showHit: true,
|
||||
showStand: true,
|
||||
showDouble: true,
|
||||
showSplit: false,
|
||||
showSurrender: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
88
Blackjack/Views/Game/CardCountView.swift
Normal file
88
Blackjack/Views/Game/CardCountView.swift
Normal file
@ -0,0 +1,88 @@
|
||||
//
|
||||
// CardCountView.swift
|
||||
// Blackjack
|
||||
//
|
||||
// Displays the Hi-Lo running and true count for card counting practice.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// Displays the Hi-Lo running count for card counting practice.
|
||||
struct CardCountView: View {
|
||||
let runningCount: Int
|
||||
let trueCount: Double
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Design.Spacing.large) {
|
||||
// Running count
|
||||
VStack(spacing: Design.Spacing.xxSmall) {
|
||||
Text("Running")
|
||||
.font(.system(size: Design.Size.cardCountLabelSize, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
|
||||
Text(runningCount >= 0 ? "+\(runningCount)" : "\(runningCount)")
|
||||
.font(.system(size: Design.Size.cardCountValueSize, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(countColor(for: runningCount))
|
||||
}
|
||||
|
||||
Divider()
|
||||
.frame(height: Design.Spacing.xLarge)
|
||||
.background(Color.white.opacity(Design.Opacity.hint))
|
||||
|
||||
// True count
|
||||
VStack(spacing: Design.Spacing.xxSmall) {
|
||||
Text("True")
|
||||
.font(.system(size: Design.Size.cardCountLabelSize, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
|
||||
Text(trueCount >= 0 ? "+\(trueCount, format: .number.precision(.fractionLength(1)))" : "\(trueCount, format: .number.precision(.fractionLength(1)))")
|
||||
.font(.system(size: Design.Size.cardCountValueSize, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(countColor(for: Int(trueCount.rounded())))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(Color.black.opacity(Design.Opacity.subtle))
|
||||
)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(localized: "Card Count"))
|
||||
.accessibilityValue(String(localized: "Running \(runningCount), True \(trueCount, format: .number.precision(.fractionLength(1)))"))
|
||||
}
|
||||
|
||||
private func countColor(for count: Int) -> Color {
|
||||
if count > 0 {
|
||||
return .green // Positive count favors player
|
||||
} else if count < 0 {
|
||||
return .red // Negative count favors house
|
||||
} else {
|
||||
return .white // Neutral
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Neutral Count") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
CardCountView(runningCount: 0, trueCount: 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Positive Count") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
CardCountView(runningCount: 7, trueCount: 2.3)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Negative Count") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
CardCountView(runningCount: -4, trueCount: -1.5)
|
||||
}
|
||||
}
|
||||
|
||||
183
Blackjack/Views/Game/GameTableView.swift
Normal file
183
Blackjack/Views/Game/GameTableView.swift
Normal file
@ -0,0 +1,183 @@
|
||||
//
|
||||
// GameTableView.swift
|
||||
// Blackjack
|
||||
//
|
||||
// Main game container view.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
struct GameTableView: View {
|
||||
@State private var settings = GameSettings()
|
||||
@State private var gameState: GameState?
|
||||
@State private var selectedChip: ChipDenomination = .twentyFive
|
||||
|
||||
// MARK: - Sheet State
|
||||
|
||||
@State private var showSettings = false
|
||||
@State private var showRules = false
|
||||
@State private var showStats = false
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
/// Whether we're on iPad
|
||||
private var isIPad: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
/// Maximum content width based on device
|
||||
private var maxContentWidth: CGFloat {
|
||||
if isIPad {
|
||||
return verticalSizeClass == .compact
|
||||
? Design.Size.maxContentWidthLandscape
|
||||
: Design.Size.maxContentWidthPortrait
|
||||
}
|
||||
return .infinity
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let state = gameState {
|
||||
mainGameView(state: state)
|
||||
} else {
|
||||
ProgressView()
|
||||
.task {
|
||||
gameState = GameState(settings: settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(settings: settings, gameState: gameState)
|
||||
}
|
||||
.sheet(isPresented: $showRules) {
|
||||
RulesHelpView()
|
||||
}
|
||||
.sheet(isPresented: $showStats) {
|
||||
if let state = gameState {
|
||||
StatisticsSheetView(state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main Game View
|
||||
|
||||
@ViewBuilder
|
||||
private func mainGameView(state: GameState) -> some View {
|
||||
ZStack {
|
||||
// Background
|
||||
TableBackgroundView()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Top bar
|
||||
TopBarView(
|
||||
balance: state.balance,
|
||||
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.cardsRemaining)" : nil,
|
||||
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
|
||||
onReset: { state.resetGame() },
|
||||
onSettings: { showSettings = true },
|
||||
onHelp: { showRules = true },
|
||||
onStats: { showStats = true }
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
|
||||
// Card count display (when enabled)
|
||||
if settings.showCardCount {
|
||||
CardCountView(
|
||||
runningCount: state.engine.runningCount,
|
||||
trueCount: state.engine.trueCount
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
}
|
||||
|
||||
// Reshuffle notification
|
||||
if state.showReshuffleNotification {
|
||||
ReshuffleNotificationView(showCardCount: settings.showCardCount)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
// Table layout
|
||||
BlackjackTableView(
|
||||
state: state,
|
||||
onPlaceBet: { placeBet(state: state) }
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Chip selector - only shown during betting phase
|
||||
if state.currentPhase == .betting {
|
||||
ChipSelectorView(
|
||||
selectedChip: $selectedChip,
|
||||
balance: state.balance,
|
||||
currentBet: state.currentBet,
|
||||
maxBet: state.settings.maxBet
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
.padding(.bottom, Design.Spacing.small)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
ActionButtonsView(state: state)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
.padding(.bottom, Design.Spacing.medium)
|
||||
}
|
||||
.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
|
||||
if state.showResultBanner, let result = state.lastRoundResult {
|
||||
ResultBannerView(
|
||||
result: result,
|
||||
currentBalance: state.balance,
|
||||
minBet: state.settings.minBet,
|
||||
onNewRound: { state.newRound() },
|
||||
onPlayAgain: { state.resetGame() }
|
||||
)
|
||||
}
|
||||
|
||||
// Confetti for wins (matching Baccarat pattern)
|
||||
if state.showResultBanner && (state.lastRoundResult?.totalWinnings ?? 0) > 0 {
|
||||
ConfettiView()
|
||||
}
|
||||
|
||||
// Game over
|
||||
if state.isGameOver && !state.showResultBanner {
|
||||
GameOverView(
|
||||
roundsPlayed: state.roundsPlayed,
|
||||
onPlayAgain: { state.resetGame() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Betting
|
||||
|
||||
private func placeBet(state: GameState) {
|
||||
state.placeBet(amount: selectedChip.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
GameTableView()
|
||||
}
|
||||
|
||||
60
Blackjack/Views/Game/ReshuffleNotificationView.swift
Normal file
60
Blackjack/Views/Game/ReshuffleNotificationView.swift
Normal file
@ -0,0 +1,60 @@
|
||||
//
|
||||
// ReshuffleNotificationView.swift
|
||||
// Blackjack
|
||||
//
|
||||
// Shows a notification when the shoe is reshuffled.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// Shows a notification when the shoe is reshuffled.
|
||||
struct ReshuffleNotificationView: View {
|
||||
let showCardCount: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: "shuffle")
|
||||
.foregroundStyle(.white)
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text("Shoe Reshuffled")
|
||||
.font(.system(size: Design.BaseFontSize.medium, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if showCardCount {
|
||||
Text("Count reset to 0")
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.blue.opacity(Design.Opacity.heavy))
|
||||
)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(showCardCount
|
||||
? String(localized: "Shoe reshuffled, count reset to zero")
|
||||
: String(localized: "Shoe reshuffled"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Without Count") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
ReshuffleNotificationView(showCardCount: false)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("With Count Reset") {
|
||||
ZStack {
|
||||
Color.Table.felt.ignoresSafeArea()
|
||||
ReshuffleNotificationView(showCardCount: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,491 +0,0 @@
|
||||
//
|
||||
// GameTableView.swift
|
||||
// Blackjack
|
||||
//
|
||||
// Main game container view.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
struct GameTableView: View {
|
||||
@State private var settings = GameSettings()
|
||||
@State private var gameState: GameState?
|
||||
@State private var selectedChip: ChipDenomination = .twentyFive
|
||||
|
||||
// MARK: - Sheet State
|
||||
|
||||
@State private var showSettings = false
|
||||
@State private var showRules = false
|
||||
@State private var showStats = false
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
/// Whether we're on iPad
|
||||
private var isIPad: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
/// Maximum content width based on device
|
||||
private var maxContentWidth: CGFloat {
|
||||
if isIPad {
|
||||
return verticalSizeClass == .compact
|
||||
? Design.Size.maxContentWidthLandscape
|
||||
: Design.Size.maxContentWidthPortrait
|
||||
}
|
||||
return .infinity
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let state = gameState {
|
||||
mainGameView(state: state)
|
||||
} else {
|
||||
ProgressView()
|
||||
.task {
|
||||
gameState = GameState(settings: settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(settings: settings, gameState: gameState)
|
||||
}
|
||||
.sheet(isPresented: $showRules) {
|
||||
RulesHelpView()
|
||||
}
|
||||
.sheet(isPresented: $showStats) {
|
||||
if let state = gameState {
|
||||
StatisticsSheetView(state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main Game View
|
||||
|
||||
@ViewBuilder
|
||||
private func mainGameView(state: GameState) -> some View {
|
||||
ZStack {
|
||||
// Background
|
||||
TableBackgroundView()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Top bar
|
||||
TopBarView(
|
||||
balance: state.balance,
|
||||
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.cardsRemaining)" : nil,
|
||||
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
|
||||
onReset: { state.resetGame() },
|
||||
onSettings: { showSettings = true },
|
||||
onHelp: { showRules = true },
|
||||
onStats: { showStats = true }
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
|
||||
// Card count display (when enabled)
|
||||
if settings.showCardCount {
|
||||
CardCountView(
|
||||
runningCount: state.engine.runningCount,
|
||||
trueCount: state.engine.trueCount
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
}
|
||||
|
||||
// Reshuffle notification
|
||||
if state.showReshuffleNotification {
|
||||
ReshuffleNotificationView(showCardCount: settings.showCardCount)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
// Table layout
|
||||
BlackjackTableView(
|
||||
state: state,
|
||||
onPlaceBet: { placeBet(state: state) }
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Chip selector - only shown during betting phase
|
||||
if state.currentPhase == .betting {
|
||||
ChipSelectorView(
|
||||
selectedChip: $selectedChip,
|
||||
balance: state.balance,
|
||||
currentBet: state.currentBet,
|
||||
maxBet: state.settings.maxBet
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
.padding(.bottom, Design.Spacing.small)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
ActionButtonsView(state: state)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
.padding(.bottom, Design.Spacing.medium)
|
||||
}
|
||||
.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
|
||||
if state.showResultBanner, let result = state.lastRoundResult {
|
||||
ResultBannerView(
|
||||
result: result,
|
||||
currentBalance: state.balance,
|
||||
minBet: state.settings.minBet,
|
||||
onNewRound: { state.newRound() },
|
||||
onPlayAgain: { state.resetGame() }
|
||||
)
|
||||
}
|
||||
|
||||
// Confetti for wins (matching Baccarat pattern)
|
||||
if state.showResultBanner && (state.lastRoundResult?.totalWinnings ?? 0) > 0 {
|
||||
ConfettiView()
|
||||
}
|
||||
|
||||
// Game over
|
||||
if state.isGameOver && !state.showResultBanner {
|
||||
GameOverView(
|
||||
roundsPlayed: state.roundsPlayed,
|
||||
onPlayAgain: { state.resetGame() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Betting
|
||||
|
||||
private func placeBet(state: GameState) {
|
||||
state.placeBet(amount: selectedChip.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action Buttons View
|
||||
|
||||
struct ActionButtonsView: View {
|
||||
@Bindable var state: GameState
|
||||
|
||||
// Scaled metrics
|
||||
@ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = Design.BaseFontSize.large
|
||||
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.IconSize.large
|
||||
|
||||
// Fixed height to prevent layout shifts
|
||||
private let containerHeight: CGFloat = 120
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.clear
|
||||
.frame(height: containerHeight)
|
||||
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
// Primary actions
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
switch state.currentPhase {
|
||||
case .betting:
|
||||
bettingButtons
|
||||
case .playerTurn:
|
||||
playerTurnButtons
|
||||
case .roundComplete:
|
||||
// Empty - handled by result banner
|
||||
EmptyView()
|
||||
default:
|
||||
// Dealing, dealer turn - show nothing
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.animation(.spring(duration: Design.Animation.quick), value: state.currentPhase)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
}
|
||||
|
||||
// MARK: - Betting Phase Buttons
|
||||
|
||||
/// Whether the current bet meets the minimum requirement
|
||||
private var isBetBelowMinimum: Bool {
|
||||
state.currentBet > 0 && state.currentBet < state.settings.minBet
|
||||
}
|
||||
|
||||
/// Amount needed to reach minimum bet
|
||||
private var amountNeededForMinimum: Int {
|
||||
state.settings.minBet - state.currentBet
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var bettingButtons: some View {
|
||||
if state.currentBet > 0 {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
// Show hint if bet is below minimum
|
||||
if isBetBelowMinimum {
|
||||
Text(String(localized: "Add $\(amountNeededForMinimum) more to meet minimum"))
|
||||
.font(.system(size: Design.BaseFontSize.small, weight: .medium))
|
||||
.foregroundStyle(.orange)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
ActionButton(
|
||||
String(localized: "Clear"),
|
||||
icon: "xmark.circle",
|
||||
style: .destructive
|
||||
) {
|
||||
state.clearBet()
|
||||
}
|
||||
|
||||
// Always show Deal button, but disable if below minimum
|
||||
ActionButton(
|
||||
String(localized: "Deal"),
|
||||
icon: "play.fill",
|
||||
style: .primary
|
||||
) {
|
||||
Task { await state.deal() }
|
||||
}
|
||||
.opacity(state.canDeal ? 1.0 : Design.Opacity.medium)
|
||||
.disabled(!state.canDeal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player Turn Buttons
|
||||
|
||||
@ViewBuilder
|
||||
private var playerTurnButtons: some View {
|
||||
// Top row: Hit, Stand
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
if state.canHit {
|
||||
ActionButton(
|
||||
String(localized: "Hit"),
|
||||
style: .custom(Color.Button.hit)
|
||||
) {
|
||||
Task { await state.hit() }
|
||||
}
|
||||
}
|
||||
|
||||
if state.canStand {
|
||||
ActionButton(
|
||||
String(localized: "Stand"),
|
||||
style: .custom(Color.Button.stand)
|
||||
) {
|
||||
Task { await state.stand() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom row: Double, Split, Surrender
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
if state.canDouble {
|
||||
ActionButton(
|
||||
String(localized: "Double"),
|
||||
style: .custom(Color.Button.doubleDown)
|
||||
) {
|
||||
Task { await state.doubleDown() }
|
||||
}
|
||||
}
|
||||
|
||||
if state.canSplit {
|
||||
ActionButton(
|
||||
String(localized: "Split"),
|
||||
style: .custom(Color.Button.split)
|
||||
) {
|
||||
Task { await state.split() }
|
||||
}
|
||||
}
|
||||
|
||||
if state.canSurrender {
|
||||
ActionButton(
|
||||
String(localized: "Surrender"),
|
||||
style: .custom(Color.Button.surrender)
|
||||
) {
|
||||
Task { await state.surrender() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action Button
|
||||
|
||||
struct ActionButton: View {
|
||||
let title: String
|
||||
let icon: String?
|
||||
let style: ButtonStyle
|
||||
let action: () -> Void
|
||||
|
||||
enum ButtonStyle {
|
||||
case primary // Gold gradient (Deal, New Round)
|
||||
case destructive // Red (Clear)
|
||||
case secondary // Subtle white
|
||||
case custom(Color) // Game-specific colors (Hit, Stand, etc.)
|
||||
|
||||
var foregroundColor: Color {
|
||||
switch self {
|
||||
case .primary: return .black
|
||||
case .destructive, .secondary, .custom: return .white
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(_ title: String, icon: String? = nil, style: ButtonStyle = .primary, action: @escaping () -> Void) {
|
||||
self.title = title
|
||||
self.icon = icon
|
||||
self.style = style
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
if let icon = icon {
|
||||
Image(systemName: icon)
|
||||
}
|
||||
Text(title)
|
||||
}
|
||||
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
|
||||
.foregroundStyle(style.foregroundColor)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
|
||||
.background(backgroundView)
|
||||
}
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var backgroundView: some View {
|
||||
switch style {
|
||||
case .primary:
|
||||
Capsule()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.Button.goldLight, Color.Button.goldDark],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
case .destructive:
|
||||
Capsule()
|
||||
.fill(Color.red.opacity(Design.Opacity.heavy))
|
||||
case .secondary:
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(Design.Opacity.hint))
|
||||
case .custom(let color):
|
||||
Capsule()
|
||||
.fill(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card Count View
|
||||
|
||||
/// Displays the Hi-Lo running count for card counting practice.
|
||||
struct CardCountView: View {
|
||||
let runningCount: Int
|
||||
let trueCount: Double
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Design.Spacing.large) {
|
||||
// Running count
|
||||
VStack(spacing: Design.Spacing.xxSmall) {
|
||||
Text("Running")
|
||||
.font(.system(size: Design.Size.cardCountLabelSize, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
|
||||
Text(runningCount >= 0 ? "+\(runningCount)" : "\(runningCount)")
|
||||
.font(.system(size: Design.Size.cardCountValueSize, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(countColor(for: runningCount))
|
||||
}
|
||||
|
||||
Divider()
|
||||
.frame(height: Design.Spacing.xLarge)
|
||||
.background(Color.white.opacity(Design.Opacity.hint))
|
||||
|
||||
// True count
|
||||
VStack(spacing: Design.Spacing.xxSmall) {
|
||||
Text("True")
|
||||
.font(.system(size: Design.Size.cardCountLabelSize, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
|
||||
Text(trueCount >= 0 ? "+\(trueCount, format: .number.precision(.fractionLength(1)))" : "\(trueCount, format: .number.precision(.fractionLength(1)))")
|
||||
.font(.system(size: Design.Size.cardCountValueSize, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(countColor(for: Int(trueCount.rounded())))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.small)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(Color.black.opacity(Design.Opacity.subtle))
|
||||
)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(localized: "Card Count"))
|
||||
.accessibilityValue(String(localized: "Running \(runningCount), True \(trueCount, format: .number.precision(.fractionLength(1)))"))
|
||||
}
|
||||
|
||||
private func countColor(for count: Int) -> Color {
|
||||
if count > 0 {
|
||||
return .green // Positive count favors player
|
||||
} else if count < 0 {
|
||||
return .red // Negative count favors house
|
||||
} else {
|
||||
return .white // Neutral
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reshuffle Notification View
|
||||
|
||||
/// Shows a notification when the shoe is reshuffled.
|
||||
struct ReshuffleNotificationView: View {
|
||||
let showCardCount: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: "shuffle")
|
||||
.foregroundStyle(.white)
|
||||
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||
Text("Shoe Reshuffled")
|
||||
.font(.system(size: Design.BaseFontSize.medium, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if showCardCount {
|
||||
Text("Count reset to 0")
|
||||
.font(.system(size: Design.BaseFontSize.small))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.blue.opacity(Design.Opacity.heavy))
|
||||
)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(showCardCount
|
||||
? String(localized: "Shoe reshuffled, count reset to zero")
|
||||
: String(localized: "Shoe reshuffled"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
GameTableView()
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user