Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
cac0af4ab3
commit
a0ac5a6e64
@ -120,6 +120,10 @@
|
||||
"comment" : "A bullet point in the \"How to Export Icons\" section, describing how to use an online tool to generate all sizes for an app icon.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"↓ then →" : {
|
||||
"comment" : "A textual instruction for using the road map in the game.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"+%lld" : {
|
||||
"comment" : "A text element displaying the total winnings in the round, prefixed by a plus sign. The argument is the total winnings amount.",
|
||||
"localizations" : {
|
||||
@ -166,6 +170,7 @@
|
||||
},
|
||||
"$" : {
|
||||
"comment" : "The currency symbol \"$\".",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -207,6 +212,7 @@
|
||||
},
|
||||
"$%@" : {
|
||||
"comment" : "The value of the balance displayed in the top bar.",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -677,6 +683,7 @@
|
||||
},
|
||||
"Balance" : {
|
||||
"comment" : "A label describing the user's current balance.",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -1215,6 +1222,7 @@
|
||||
},
|
||||
"Cards remaining in shoe" : {
|
||||
"comment" : "A label describing the number of cards remaining in the shoe.",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -2123,6 +2131,7 @@
|
||||
},
|
||||
"Help" : {
|
||||
"comment" : "The label of a button that shows help information.",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -3423,6 +3432,7 @@
|
||||
},
|
||||
"Reset" : {
|
||||
"comment" : "A button that resets the game to its initial state.",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -4453,6 +4463,7 @@
|
||||
},
|
||||
"View detailed game statistics" : {
|
||||
"comment" : "A hint that appears when hovering over the \"Statistics\" button, explaining its function.",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
|
||||
@ -13,6 +13,9 @@ import CasinoKit
|
||||
/// Shared constants are imported from CasinoDesign; game-specific values are defined here.
|
||||
enum Design {
|
||||
|
||||
/// Set to true to show layout debug borders on views
|
||||
static let showDebugBorders = false
|
||||
|
||||
// MARK: - Shared Constants (from CasinoKit)
|
||||
|
||||
typealias Spacing = CasinoDesign.Spacing
|
||||
@ -34,7 +37,7 @@ enum Design {
|
||||
/// Hand scaling factor for cards and related elements.
|
||||
/// 1.0 = original size, 1.5 = 50% larger, 2.0 = double size.
|
||||
/// Adjust this value to change card sizes across the app.
|
||||
static let handScale: CGFloat = 1.5
|
||||
static let handScale: CGFloat = 1.75
|
||||
|
||||
/// Scale multiplier for small screens (iPhone SE, etc).
|
||||
/// Applied instead of handScale on screens narrower than smallScreenThreshold.
|
||||
@ -56,11 +59,16 @@ enum Design {
|
||||
/// Base card width before scaling (for reference)
|
||||
private static let cardWidthTableBase: CGFloat = 45
|
||||
|
||||
/// Base card overlap before scaling.
|
||||
/// More negative = more overlap (less card visible).
|
||||
/// -15 is default, -20 shows less card, -25 shows even less.
|
||||
private static let cardOverlapBase: CGFloat = -25
|
||||
|
||||
/// Card overlap scaled with hand size (standard iPhone)
|
||||
static let cardOverlap: CGFloat = CasinoDesign.Size.cardOverlap * handScale
|
||||
static let cardOverlap: CGFloat = cardOverlapBase * handScale
|
||||
|
||||
/// Card overlap for small screens
|
||||
static let cardOverlapSmall: CGFloat = CasinoDesign.Size.cardOverlap * smallScreenScale
|
||||
static let cardOverlapSmall: CGFloat = cardOverlapBase * smallScreenScale
|
||||
|
||||
// Baccarat table cards - scaled for better visibility (standard iPhone)
|
||||
static let cardWidthTable: CGFloat = cardWidthTableBase * handScale
|
||||
@ -72,7 +80,7 @@ enum Design {
|
||||
static let cardWidthTableLarge: CGFloat = cardWidthTableBase * handScale * largeScreenMultiplier
|
||||
|
||||
/// Card overlap for large screens
|
||||
static let cardOverlapLarge: CGFloat = CasinoDesign.Size.cardOverlap * handScale * largeScreenMultiplier
|
||||
static let cardOverlapLarge: CGFloat = cardOverlapBase * handScale * largeScreenMultiplier
|
||||
|
||||
// Chips - use CasinoDesign values
|
||||
static let chipSmall: CGFloat = CasinoDesign.Size.chipSmall
|
||||
|
||||
201
Baccarat/Baccarat/Views/Game/ActionButtonsView.swift
Normal file
201
Baccarat/Baccarat/Views/Game/ActionButtonsView.swift
Normal file
@ -0,0 +1,201 @@
|
||||
//
|
||||
// ActionButtonsView.swift
|
||||
// Baccarat
|
||||
//
|
||||
// Action buttons for deal, clear, and new round.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// Action buttons for deal, clear, and new round.
|
||||
struct ActionButtonsView: View {
|
||||
@Bindable var gameState: GameState
|
||||
let onDeal: () -> Void
|
||||
let onClear: () -> Void
|
||||
let onNewRound: () -> Void
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
|
||||
/// Whether the current text size is an accessibility size (very large)
|
||||
private var isAccessibilitySize: Bool {
|
||||
dynamicTypeSize.isAccessibilitySize
|
||||
}
|
||||
|
||||
// MARK: - Layout Constants
|
||||
|
||||
private let buttonFontSize: CGFloat = Design.BaseFontSize.xLarge
|
||||
private let iconSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall
|
||||
private let statusFontSize: CGFloat = Design.BaseFontSize.medium
|
||||
private let containerHeight: CGFloat = 50
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Fixed height container to prevent layout shifts
|
||||
Color.clear
|
||||
.frame(height: containerHeight)
|
||||
|
||||
// Content changes with animation
|
||||
Group {
|
||||
if gameState.currentPhase == .betting {
|
||||
bettingButtons
|
||||
} else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner {
|
||||
newRoundButton
|
||||
} else if !gameState.showResultBanner {
|
||||
dealingIndicator
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: Design.Animation.quick), value: gameState.currentPhase)
|
||||
.animation(.easeInOut(duration: Design.Animation.quick), value: gameState.showResultBanner)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Views
|
||||
|
||||
private var bettingButtons: some View {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
clearButton
|
||||
dealButton
|
||||
}
|
||||
}
|
||||
|
||||
private var dealingIndicator: some View {
|
||||
HStack(spacing: Design.Spacing.xSmall) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(0.8)
|
||||
Text("Dealing...")
|
||||
.font(.system(size: statusFontSize, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.heavy))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(Design.MinScaleFactor.relaxed)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xLarge)
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var clearButton: some View {
|
||||
if isAccessibilitySize {
|
||||
Button("Clear", systemImage: "xmark.circle", action: onClear)
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.system(size: iconSize, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(Design.Spacing.medium + Design.Spacing.xxSmall)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.Button.destructive)
|
||||
)
|
||||
.opacity(gameState.currentBets.isEmpty ? Design.Opacity.disabled : 1.0)
|
||||
.disabled(gameState.currentBets.isEmpty)
|
||||
} else {
|
||||
Button("Clear", systemImage: "xmark.circle", action: onClear)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.system(size: buttonFontSize, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.Button.destructive)
|
||||
)
|
||||
.opacity(gameState.currentBets.isEmpty ? Design.Opacity.disabled : 1.0)
|
||||
.disabled(gameState.currentBets.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var dealButton: some View {
|
||||
let buttonBackground = LinearGradient(
|
||||
colors: [Color.Button.goldLight, Color.Button.goldDark],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
if isAccessibilitySize {
|
||||
Button("Deal", systemImage: "play.fill", action: onDeal)
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.system(size: iconSize, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
.padding(Design.Spacing.medium + Design.Spacing.xxSmall)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(buttonBackground)
|
||||
)
|
||||
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium)
|
||||
.opacity(gameState.canDeal ? 1.0 : Design.Opacity.disabled)
|
||||
.disabled(!gameState.canDeal)
|
||||
} else {
|
||||
Button("Deal", systemImage: "play.fill", action: onDeal)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.system(size: buttonFontSize, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(buttonBackground)
|
||||
)
|
||||
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium)
|
||||
.opacity(gameState.canDeal ? 1.0 : Design.Opacity.disabled)
|
||||
.disabled(!gameState.canDeal)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var newRoundButton: some View {
|
||||
let buttonBackground = LinearGradient(
|
||||
colors: [Color.Button.goldLight, Color.Button.goldDark],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
if isAccessibilitySize {
|
||||
Button("New Round", systemImage: "arrow.clockwise", action: onNewRound)
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.system(size: iconSize, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
.padding(Design.Spacing.medium + Design.Spacing.xxSmall)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(buttonBackground)
|
||||
)
|
||||
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium)
|
||||
} else {
|
||||
Button("New Round", systemImage: "arrow.clockwise", action: onNewRound)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.system(size: buttonFontSize, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(buttonBackground)
|
||||
)
|
||||
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Betting Phase") {
|
||||
ZStack {
|
||||
TableBackgroundView()
|
||||
VStack {
|
||||
Spacer()
|
||||
ActionButtonsView(
|
||||
gameState: GameState(settings: GameSettings()),
|
||||
onDeal: {},
|
||||
onClear: {},
|
||||
onNewRound: {}
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
384
Baccarat/Baccarat/Views/Game/GameTableView.swift
Normal file
384
Baccarat/Baccarat/Views/Game/GameTableView.swift
Normal file
@ -0,0 +1,384 @@
|
||||
//
|
||||
// GameTableView.swift
|
||||
// Baccarat
|
||||
//
|
||||
// The main baccarat table view with all game elements.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// The main game table view containing all game elements.
|
||||
struct GameTableView: View {
|
||||
@State private var settings = GameSettings()
|
||||
@State private var gameState: GameState?
|
||||
@State private var selectedChip: ChipDenomination = .hundred
|
||||
@State private var showSettings = false
|
||||
@State private var showRules = false
|
||||
@State private var showStats = false
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Whether we're on iPad or large screen
|
||||
private var isLargeScreen: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
/// Whether we're in landscape mode (compact vertical on iPad)
|
||||
private var isLandscape: Bool {
|
||||
verticalSizeClass == .compact
|
||||
}
|
||||
|
||||
/// Extra bottom padding for landscape mode where vertical space is tight
|
||||
private var bottomPadding: CGFloat {
|
||||
isLandscape ? Design.Spacing.medium : Design.Spacing.xSmall
|
||||
}
|
||||
|
||||
/// Minimum spacer height - smaller in landscape to fit content
|
||||
private var minSpacerHeight: CGFloat {
|
||||
isLandscape ? 0 : Design.Spacing.xSmall
|
||||
}
|
||||
|
||||
/// Smaller spacer height - reduced in landscape
|
||||
private var smallSpacerHeight: CGFloat {
|
||||
isLandscape ? Design.Spacing.xxSmall : Design.Spacing.small
|
||||
}
|
||||
|
||||
/// Medium spacer height - reduced in landscape
|
||||
private var mediumSpacerHeight: CGFloat {
|
||||
isLandscape ? Design.Spacing.xSmall : Design.Spacing.medium
|
||||
}
|
||||
|
||||
/// Maximum width for game content on large screens
|
||||
private var maxContentWidth: CGFloat {
|
||||
isLandscape ? Design.Size.maxContentWidthLandscape : Design.Size.maxContentWidthPortrait
|
||||
}
|
||||
|
||||
private var state: GameState {
|
||||
gameState ?? GameState(settings: settings)
|
||||
}
|
||||
|
||||
private var playerIsWinner: Bool {
|
||||
state.lastResult == .playerWins
|
||||
}
|
||||
|
||||
private var bankerIsWinner: Bool {
|
||||
state.lastResult == .bankerWins
|
||||
}
|
||||
|
||||
private var isTie: Bool {
|
||||
state.lastResult == .tie
|
||||
}
|
||||
|
||||
// Use global debug flag from Design constants
|
||||
private var showDebugBorders: Bool { Design.showDebugBorders }
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Table background (from CasinoKit)
|
||||
TableBackgroundView()
|
||||
|
||||
// Main content
|
||||
mainContent(geometry: geometry)
|
||||
|
||||
// Overlays
|
||||
overlays
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if gameState == nil {
|
||||
gameState = GameState(settings: settings)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
if let state = gameState {
|
||||
SettingsView(settings: settings, gameState: state) {
|
||||
gameState?.applySettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showRules) {
|
||||
RulesHelpView()
|
||||
}
|
||||
.sheet(isPresented: $showStats) {
|
||||
StatisticsSheetView(results: state.roundHistory)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Views
|
||||
|
||||
@ViewBuilder
|
||||
private func mainContent(geometry: GeometryProxy) -> some View {
|
||||
let screenWidth = geometry.size.width
|
||||
let screenHeight = geometry.size.height
|
||||
// Use geometry to detect landscape on iPad (width > height and large screen)
|
||||
let isLandscapeLayout = isLargeScreen && screenWidth > screenHeight
|
||||
|
||||
if isLandscapeLayout {
|
||||
// Landscape iPad: RoadMap on left, game content on right
|
||||
landscapeLayout(screenWidth: screenWidth)
|
||||
} else {
|
||||
// Portrait or iPhone: vertical stack with RoadMap inline
|
||||
portraitLayout(screenWidth: screenWidth)
|
||||
}
|
||||
}
|
||||
|
||||
/// Landscape layout with TopBar spanning full width, RoadMap grid on left below TopBar
|
||||
private func landscapeLayout(screenWidth: CGFloat) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
// Top bar spans full width
|
||||
TopBarView(
|
||||
balance: state.balance,
|
||||
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.shoe.cardsRemaining)" : nil,
|
||||
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
|
||||
onReset: { state.resetGame() },
|
||||
onSettings: { showSettings = true },
|
||||
onHelp: { showRules = true },
|
||||
onStats: { showStats = true }
|
||||
)
|
||||
.debugBorder(showDebugBorders, color: .cyan, label: "TopBar")
|
||||
|
||||
// Main content area with optional sidebar
|
||||
HStack(spacing: 0) {
|
||||
// Left side: Road map history grid
|
||||
if settings.showHistory && !state.roundHistory.isEmpty {
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
|
||||
// Header with reading instructions
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("HISTORY")
|
||||
.font(.system(size: Design.BaseFontSize.small, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
|
||||
Text("↓ then →")
|
||||
.font(.system(size: Design.BaseFontSize.xSmall, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.small)
|
||||
.padding(.top, Design.Spacing.small)
|
||||
|
||||
// Grid-based road map (rows calculated dynamically)
|
||||
RoadMapGridView(
|
||||
results: state.recentResults,
|
||||
dotSize: 32
|
||||
)
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(width: 240)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||
.fill(Color.black.opacity(Design.Opacity.light))
|
||||
.padding(Design.Spacing.xSmall)
|
||||
)
|
||||
.debugBorder(showDebugBorders, color: .orange, label: "RoadMap")
|
||||
}
|
||||
|
||||
// Right side: Main game content
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Cards display area
|
||||
CardsDisplayArea(
|
||||
playerCards: state.visiblePlayerCards,
|
||||
bankerCards: state.visibleBankerCards,
|
||||
playerCardsFaceUp: state.playerCardsFaceUp,
|
||||
bankerCardsFaceUp: state.bankerCardsFaceUp,
|
||||
playerValue: state.playerHandValue,
|
||||
bankerValue: state.bankerHandValue,
|
||||
playerIsWinner: playerIsWinner,
|
||||
bankerIsWinner: bankerIsWinner,
|
||||
isTie: isTie,
|
||||
screenWidth: screenWidth
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
.debugBorder(showDebugBorders, color: .red, label: "CardsArea")
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Betting table
|
||||
BettingTableView(
|
||||
gameState: state,
|
||||
selectedChip: selectedChip
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.debugBorder(showDebugBorders, color: .blue, label: "BetTable")
|
||||
|
||||
Spacer(minLength: Design.Spacing.xSmall)
|
||||
|
||||
// Chip selector
|
||||
ChipSelectorView(
|
||||
selectedChip: $selectedChip,
|
||||
balance: state.balance,
|
||||
currentBet: state.totalBetAmount,
|
||||
maxBet: state.maxBet
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth)
|
||||
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
|
||||
|
||||
Spacer(minLength: Design.Spacing.xSmall)
|
||||
|
||||
// Action buttons
|
||||
ActionButtonsView(
|
||||
gameState: state,
|
||||
onDeal: {
|
||||
Task {
|
||||
await state.deal()
|
||||
}
|
||||
},
|
||||
onClear: { state.clearBets() },
|
||||
onNewRound: { state.newRound() }
|
||||
)
|
||||
.frame(maxWidth: maxContentWidth * 0.8)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, Design.Spacing.small)
|
||||
.debugBorder(showDebugBorders, color: .green, label: "ActionBtns")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.debugBorder(showDebugBorders, color: .white, label: "GameContent")
|
||||
}
|
||||
}
|
||||
.safeAreaPadding(.bottom, Design.Spacing.small)
|
||||
}
|
||||
|
||||
/// Portrait layout with RoadMap inline
|
||||
private func portraitLayout(screenWidth: CGFloat) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
// Top bar with balance and info (from CasinoKit)
|
||||
TopBarView(
|
||||
balance: state.balance,
|
||||
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.shoe.cardsRemaining)" : nil,
|
||||
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
|
||||
onReset: { state.resetGame() },
|
||||
onSettings: { showSettings = true },
|
||||
onHelp: { showRules = true },
|
||||
onStats: { showStats = true }
|
||||
)
|
||||
.debugBorder(showDebugBorders, color: .cyan, label: "TopBar")
|
||||
|
||||
Spacer(minLength: minSpacerHeight)
|
||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer1")
|
||||
|
||||
// Cards display area
|
||||
CardsDisplayArea(
|
||||
playerCards: state.visiblePlayerCards,
|
||||
bankerCards: state.visibleBankerCards,
|
||||
playerCardsFaceUp: state.playerCardsFaceUp,
|
||||
bankerCardsFaceUp: state.bankerCardsFaceUp,
|
||||
playerValue: state.playerHandValue,
|
||||
bankerValue: state.bankerHandValue,
|
||||
playerIsWinner: playerIsWinner,
|
||||
bankerIsWinner: bankerIsWinner,
|
||||
isTie: isTie,
|
||||
screenWidth: screenWidth
|
||||
)
|
||||
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
|
||||
.debugBorder(showDebugBorders, color: .red, label: "CardsArea")
|
||||
|
||||
Spacer(minLength: minSpacerHeight)
|
||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer2")
|
||||
|
||||
// Road map history
|
||||
if settings.showHistory && !state.roundHistory.isEmpty {
|
||||
RoadMapView(results: state.recentResults)
|
||||
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
|
||||
.padding(.horizontal)
|
||||
.debugBorder(showDebugBorders, color: .orange, label: "RoadMap")
|
||||
}
|
||||
|
||||
Spacer(minLength: smallSpacerHeight)
|
||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer3")
|
||||
|
||||
// Betting table
|
||||
BettingTableView(
|
||||
gameState: state,
|
||||
selectedChip: selectedChip
|
||||
)
|
||||
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
|
||||
.padding(.horizontal, Design.Spacing.medium)
|
||||
.debugBorder(showDebugBorders, color: .blue, label: "BetTable")
|
||||
|
||||
Spacer(minLength: mediumSpacerHeight)
|
||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer4")
|
||||
|
||||
// Chip selector (from CasinoKit)
|
||||
ChipSelectorView(
|
||||
selectedChip: $selectedChip,
|
||||
balance: state.balance,
|
||||
currentBet: state.totalBetAmount,
|
||||
maxBet: state.maxBet
|
||||
)
|
||||
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
|
||||
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
|
||||
|
||||
Spacer(minLength: smallSpacerHeight)
|
||||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer5")
|
||||
|
||||
// Action buttons
|
||||
ActionButtonsView(
|
||||
gameState: state,
|
||||
onDeal: {
|
||||
Task {
|
||||
await state.deal()
|
||||
}
|
||||
},
|
||||
onClear: { state.clearBets() },
|
||||
onNewRound: { state.newRound() }
|
||||
)
|
||||
.frame(maxWidth: isLargeScreen ? maxContentWidth * 0.8 : .infinity)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, bottomPadding)
|
||||
.debugBorder(showDebugBorders, color: .green, label: "ActionBtns")
|
||||
}
|
||||
.safeAreaPadding(.bottom)
|
||||
.debugBorder(showDebugBorders, color: .white, label: "MainContent")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var overlays: some View {
|
||||
// Result banner overlay
|
||||
if state.showResultBanner, let result = state.lastResult {
|
||||
ResultBannerView(
|
||||
result: result,
|
||||
totalWinnings: state.lastWinnings,
|
||||
betResults: state.betResults,
|
||||
playerHadPair: state.playerHadPair,
|
||||
bankerHadPair: state.bankerHadPair,
|
||||
currentBalance: state.balance,
|
||||
minBet: state.minBet,
|
||||
onNewRound: { state.newRound() },
|
||||
onGameOver: {
|
||||
state.resetGame()
|
||||
}
|
||||
)
|
||||
.transition(.opacity)
|
||||
|
||||
// Confetti for wins (from CasinoKit)
|
||||
if state.lastWinnings > 0 {
|
||||
ConfettiView()
|
||||
}
|
||||
}
|
||||
|
||||
// Game Over overlay
|
||||
if state.balance == 0 && state.currentBets.isEmpty && !state.isAnimating {
|
||||
GameOverView(
|
||||
roundsPlayed: state.roundHistory.count,
|
||||
onPlayAgain: { state.resetGame() }
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Game Table") {
|
||||
GameTableView()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
189
Baccarat/Baccarat/Views/Sheets/GameOverView.swift
Normal file
189
Baccarat/Baccarat/Views/Sheets/GameOverView.swift
Normal file
@ -0,0 +1,189 @@
|
||||
//
|
||||
// GameOverView.swift
|
||||
// Baccarat
|
||||
//
|
||||
// Game over screen shown when player runs out of money.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// Game over screen shown when player runs out of money.
|
||||
struct GameOverView: View {
|
||||
let roundsPlayed: Int
|
||||
let onPlayAgain: () -> Void
|
||||
|
||||
@State private var showContent = false
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
/// Maximum width for the modal card on iPad
|
||||
private var maxModalWidth: CGFloat {
|
||||
horizontalSizeClass == .regular ? Design.Size.maxModalWidth : .infinity
|
||||
}
|
||||
|
||||
// MARK: - Scaled Font Sizes (Dynamic Type)
|
||||
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var iconSize: CGFloat = Design.BaseFontSize.display
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var titleFontSize: CGFloat = Design.BaseFontSize.largeTitle
|
||||
@ScaledMetric(relativeTo: .body) private var messageFontSize: CGFloat = Design.BaseFontSize.xLarge
|
||||
@ScaledMetric(relativeTo: .body) private var statsFontSize: CGFloat = 17
|
||||
@ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = Design.BaseFontSize.xLarge
|
||||
|
||||
// MARK: - Layout Constants
|
||||
|
||||
private let modalCornerRadius = Design.CornerRadius.xxxLarge
|
||||
private let statsCornerRadius = Design.CornerRadius.large
|
||||
private let cardPadding = Design.Spacing.xxxLarge
|
||||
private let contentSpacing: CGFloat = 28
|
||||
private let buttonHorizontalPadding: CGFloat = 48
|
||||
private let buttonVerticalPadding: CGFloat = 18
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Solid dark backdrop - fully opaque
|
||||
Color.black
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Modal card
|
||||
modalContent
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: Design.Animation.springBounce)) {
|
||||
showContent = true
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(String(localized: "Game Over"))
|
||||
.accessibilityAddTraits(.isModal)
|
||||
}
|
||||
|
||||
// MARK: - Private Views
|
||||
|
||||
private var modalContent: some View {
|
||||
VStack(spacing: contentSpacing) {
|
||||
// Broke icon
|
||||
Image(systemName: "creditcard.trianglebadge.exclamationmark")
|
||||
.font(.system(size: iconSize))
|
||||
.foregroundStyle(.red)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
|
||||
// Title
|
||||
Text("GAME OVER")
|
||||
.font(.system(size: titleFontSize, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
// Message
|
||||
Text("You've run out of chips!")
|
||||
.font(.system(size: messageFontSize, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
||||
|
||||
// Stats card
|
||||
statsCard
|
||||
|
||||
// Play Again button
|
||||
playAgainButton
|
||||
}
|
||||
.padding(cardPadding)
|
||||
.background(modalBackground)
|
||||
.shadow(color: .red.opacity(Design.Opacity.hint), radius: Design.Shadow.radiusXXLarge)
|
||||
.frame(maxWidth: maxModalWidth)
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
.scaleEffect(showContent ? Design.Scale.normal : Design.Scale.slightShrink)
|
||||
.opacity(showContent ? 1.0 : 0)
|
||||
}
|
||||
|
||||
private var statsCard: some View {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
HStack {
|
||||
Text("Rounds Played")
|
||||
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||
Spacer()
|
||||
Text("\(roundsPlayed)")
|
||||
.bold()
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.font(.system(size: statsFontSize))
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: statsCornerRadius)
|
||||
.fill(Color.white.opacity(Design.Opacity.subtle))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: statsCornerRadius)
|
||||
.strokeBorder(Color.white.opacity(Design.Opacity.subtle), lineWidth: Design.LineWidth.thin)
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, Design.Spacing.xLarge)
|
||||
}
|
||||
|
||||
private var playAgainButton: some View {
|
||||
Button {
|
||||
onPlayAgain()
|
||||
} label: {
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
Text("Play Again")
|
||||
}
|
||||
.font(.system(size: buttonFontSize, weight: .bold))
|
||||
.foregroundStyle(.black)
|
||||
.padding(.horizontal, buttonHorizontalPadding)
|
||||
.padding(.vertical, buttonVerticalPadding)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.Button.goldLight, Color.Button.goldDark],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
)
|
||||
.shadow(color: .yellow.opacity(Design.Opacity.light), radius: Design.Shadow.radiusXLarge)
|
||||
}
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
}
|
||||
|
||||
private var modalBackground: some View {
|
||||
RoundedRectangle(cornerRadius: modalCornerRadius)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.Modal.backgroundLight, Color.Modal.backgroundDark],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: modalCornerRadius)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.red.opacity(Design.Opacity.medium),
|
||||
Color.red.opacity(Design.Opacity.hint)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
lineWidth: Design.LineWidth.medium
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Game Over") {
|
||||
GameOverView(
|
||||
roundsPlayed: 42,
|
||||
onPlayAgain: {}
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Few Rounds") {
|
||||
GameOverView(
|
||||
roundsPlayed: 3,
|
||||
onPlayAgain: {}
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,22 +1,41 @@
|
||||
//
|
||||
// MiniBaccaratTableView.swift
|
||||
// BettingTableView.swift
|
||||
// Baccarat
|
||||
//
|
||||
// A modern baccarat table layout with all betting options.
|
||||
// The baccarat betting table layout with main bets and side bets.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// The baccarat betting table layout with main bets and side bets.
|
||||
struct MiniBaccaratTableView: View {
|
||||
struct BettingTableView: View {
|
||||
@Bindable var gameState: GameState
|
||||
let selectedChip: ChipDenomination
|
||||
|
||||
// MARK: - Fixed Font Sizes
|
||||
// MARK: - Environment
|
||||
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
/// Whether we're in landscape mode (compact vertical)
|
||||
private var isLandscape: Bool {
|
||||
verticalSizeClass == .compact
|
||||
}
|
||||
|
||||
// MARK: - Adaptive Sizes
|
||||
|
||||
private let tableLimitsFontSize: CGFloat = Design.BaseFontSize.small
|
||||
|
||||
/// Top bet row height - shorter in landscape
|
||||
private var topRowHeight: CGFloat {
|
||||
isLandscape ? 40 : Design.Size.topBetRowHeight
|
||||
}
|
||||
|
||||
/// Main bet row height - shorter in landscape
|
||||
private var mainRowHeight: CGFloat {
|
||||
isLandscape ? 50 : Design.Size.mainBetRowHeight
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private func betAmount(for type: BetType) -> Int {
|
||||
@ -49,6 +68,9 @@ struct MiniBaccaratTableView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// Use global debug flag from Design constants
|
||||
private var showDebugBorders: Bool { Design.showDebugBorders }
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
@ -60,6 +82,7 @@ struct MiniBaccaratTableView: View {
|
||||
.tracking(1)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
||||
.debugBorder(showDebugBorders, color: .gray, label: "Limits")
|
||||
|
||||
// Main betting table
|
||||
VStack(spacing: 0) {
|
||||
@ -74,10 +97,12 @@ struct MiniBaccaratTableView: View {
|
||||
isBankerPairAtMax: isAtMax(for: .bankerPair),
|
||||
isTieAtMax: isAtMax(for: .tie),
|
||||
isPlayerPairAtMax: isAtMax(for: .playerPair),
|
||||
rowHeight: topRowHeight,
|
||||
onBankerPair: { gameState.placeBet(type: .bankerPair, amount: selectedChip.rawValue) },
|
||||
onTie: { gameState.placeBet(type: .tie, amount: selectedChip.rawValue) },
|
||||
onPlayerPair: { gameState.placeBet(type: .playerPair, amount: selectedChip.rawValue) }
|
||||
)
|
||||
.debugBorder(showDebugBorders, color: .purple, label: "TopRow")
|
||||
|
||||
// Divider
|
||||
Rectangle()
|
||||
@ -96,9 +121,11 @@ struct MiniBaccaratTableView: View {
|
||||
isMainAtMax: isAtMax(for: .banker),
|
||||
isBonusAtMax: isAtMax(for: .dragonBonusBanker),
|
||||
mainColor: Color.BettingZone.bankerDark,
|
||||
rowHeight: mainRowHeight,
|
||||
onMain: { gameState.placeBet(type: .banker, amount: selectedChip.rawValue) },
|
||||
onBonus: { gameState.placeBet(type: .dragonBonusBanker, amount: selectedChip.rawValue) }
|
||||
)
|
||||
.debugBorder(showDebugBorders, color: .red, label: "BankerRow")
|
||||
|
||||
// Divider
|
||||
Rectangle()
|
||||
@ -117,9 +144,11 @@ struct MiniBaccaratTableView: View {
|
||||
isMainAtMax: isAtMax(for: .player),
|
||||
isBonusAtMax: isAtMax(for: .dragonBonusPlayer),
|
||||
mainColor: Color.BettingZone.playerDark,
|
||||
rowHeight: mainRowHeight,
|
||||
onMain: { gameState.placeBet(type: .player, amount: selectedChip.rawValue) },
|
||||
onBonus: { gameState.placeBet(type: .dragonBonusPlayer, amount: selectedChip.rawValue) }
|
||||
)
|
||||
.debugBorder(showDebugBorders, color: .blue, label: "PlayerRow")
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large))
|
||||
.overlay(
|
||||
@ -135,6 +164,7 @@ struct MiniBaccaratTableView: View {
|
||||
)
|
||||
.shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge)
|
||||
}
|
||||
.debugBorder(showDebugBorders, color: .orange, label: "BettingTable")
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,6 +180,7 @@ private struct TopBettingRow: View {
|
||||
let isBankerPairAtMax: Bool
|
||||
let isTieAtMax: Bool
|
||||
let isPlayerPairAtMax: Bool
|
||||
let rowHeight: CGFloat
|
||||
let onBankerPair: () -> Void
|
||||
let onTie: () -> Void
|
||||
let onPlayerPair: () -> Void
|
||||
@ -194,7 +225,7 @@ private struct TopBettingRow: View {
|
||||
action: onPlayerPair
|
||||
)
|
||||
}
|
||||
.frame(height: Design.Size.topBetRowHeight)
|
||||
.frame(height: rowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@ -304,6 +335,7 @@ private struct MainBetRow: View {
|
||||
let isMainAtMax: Bool
|
||||
let isBonusAtMax: Bool
|
||||
let mainColor: Color
|
||||
let rowHeight: CGFloat
|
||||
let onMain: () -> Void
|
||||
let onBonus: () -> Void
|
||||
|
||||
@ -335,7 +367,7 @@ private struct MainBetRow: View {
|
||||
)
|
||||
.frame(width: Design.Size.bonusZoneWidth)
|
||||
}
|
||||
.frame(height: Design.Size.mainBetRowHeight)
|
||||
.frame(height: rowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@ -528,13 +560,31 @@ private struct ChipBadge: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Betting Table") {
|
||||
ZStack {
|
||||
Color.Table.baseDark
|
||||
.ignoresSafeArea()
|
||||
TableBackgroundView()
|
||||
|
||||
MiniBaccaratTableView(
|
||||
gameState: GameState(),
|
||||
BettingTableView(
|
||||
gameState: GameState(settings: GameSettings()),
|
||||
selectedChip: .hundred
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("With Bets") {
|
||||
ZStack {
|
||||
TableBackgroundView()
|
||||
|
||||
BettingTableView(
|
||||
gameState: {
|
||||
let state = GameState(settings: GameSettings())
|
||||
state.placeBet(type: .player, amount: 100)
|
||||
state.placeBet(type: .tie, amount: 25)
|
||||
return state
|
||||
}(),
|
||||
selectedChip: .hundred
|
||||
)
|
||||
.padding()
|
||||
246
Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift
Normal file
246
Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift
Normal file
@ -0,0 +1,246 @@
|
||||
//
|
||||
// CardsDisplayArea.swift
|
||||
// Baccarat
|
||||
//
|
||||
// The cards display area showing both Player and Banker hands.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// The cards display area showing both hands.
|
||||
struct CardsDisplayArea: View {
|
||||
let playerCards: [Card]
|
||||
let bankerCards: [Card]
|
||||
let playerCardsFaceUp: [Bool]
|
||||
let bankerCardsFaceUp: [Bool]
|
||||
let playerValue: Int
|
||||
let bankerValue: Int
|
||||
let playerIsWinner: Bool
|
||||
let bankerIsWinner: Bool
|
||||
let isTie: Bool
|
||||
/// Screen width for responsive card sizing
|
||||
var screenWidth: CGFloat = 400
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Whether we're on a large screen (iPad)
|
||||
private var isLargeScreen: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
// Use global debug flag from Design constants
|
||||
private var showDebugBorders: Bool { Design.showDebugBorders }
|
||||
|
||||
/// Label font size - only scales on iPad to avoid clipping on small iPhones
|
||||
private var labelFontSize: CGFloat {
|
||||
let baseSize: CGFloat = 14
|
||||
return isLargeScreen ? baseSize * Design.Size.handScale * Design.Size.largeScreenMultiplier : baseSize
|
||||
}
|
||||
|
||||
/// Minimum height for label row - only scales on iPad
|
||||
private var labelRowMinHeight: CGFloat {
|
||||
let baseHeight: CGFloat = 30
|
||||
return isLargeScreen ? baseHeight * Design.Size.handScale * Design.Size.largeScreenMultiplier : baseHeight
|
||||
}
|
||||
|
||||
/// Spacing between PLAYER and BANKER hands - reduced on smaller screens
|
||||
private var handsSpacing: CGFloat {
|
||||
isLargeScreen ? Design.Spacing.xxxLarge : Design.Spacing.small
|
||||
}
|
||||
|
||||
/// Horizontal padding inside the container - minimal on phones to maximize card size
|
||||
private var containerPaddingH: CGFloat {
|
||||
isLargeScreen ? Design.Spacing.xLarge : Design.Spacing.xSmall
|
||||
}
|
||||
|
||||
/// Outer horizontal padding - minimal on phones for edge-to-edge appearance
|
||||
private var outerPaddingH: CGFloat {
|
||||
isLargeScreen ? Design.Spacing.large : Design.Spacing.xSmall
|
||||
}
|
||||
|
||||
// MARK: - Accessibility
|
||||
|
||||
private var playerHandDescription: String {
|
||||
if playerCards.isEmpty {
|
||||
return String(localized: "No cards")
|
||||
}
|
||||
let visibleCards = zip(playerCards, playerCardsFaceUp)
|
||||
.filter { $1 }
|
||||
.map { "\($0.0.rank.accessibilityName) of \($0.0.suit.accessibilityName)" }
|
||||
|
||||
if visibleCards.isEmpty {
|
||||
return String(localized: "Cards face down")
|
||||
}
|
||||
|
||||
let format = String(localized: "handValueFormat")
|
||||
return visibleCards.joined(separator: ", ") + ". " + String(format: format, playerValue)
|
||||
}
|
||||
|
||||
private var bankerHandDescription: String {
|
||||
if bankerCards.isEmpty {
|
||||
return String(localized: "No cards")
|
||||
}
|
||||
let visibleCards = zip(bankerCards, bankerCardsFaceUp)
|
||||
.filter { $1 }
|
||||
.map { "\($0.0.rank.accessibilityName) of \($0.0.suit.accessibilityName)" }
|
||||
|
||||
if visibleCards.isEmpty {
|
||||
return String(localized: "Cards face down")
|
||||
}
|
||||
|
||||
let format = String(localized: "handValueFormat")
|
||||
return visibleCards.joined(separator: ", ") + ". " + String(format: format, bankerValue)
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: handsSpacing) {
|
||||
// Player side
|
||||
playerHandSection
|
||||
.debugBorder(showDebugBorders, color: .blue, label: "Player")
|
||||
|
||||
// Banker side
|
||||
bankerHandSection
|
||||
.debugBorder(showDebugBorders, color: .red, label: "Banker")
|
||||
}
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
.padding(.bottom, Design.Spacing.large)
|
||||
.padding(.horizontal, containerPaddingH)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.xLarge)
|
||||
.fill(Color.black.opacity(Design.Opacity.quarter))
|
||||
.accessibilityHidden(true)
|
||||
)
|
||||
.padding(.horizontal, outerPaddingH)
|
||||
.debugBorder(showDebugBorders, color: .mint, label: "HandsContainer")
|
||||
}
|
||||
|
||||
// MARK: - Private Views
|
||||
|
||||
private var playerHandSection: some View {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
// Label with value
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Text("PLAYER")
|
||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if !playerCards.isEmpty && playerCardsFaceUp.contains(true) {
|
||||
HandValueBadge(value: playerValue, color: .blue)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: labelRowMinHeight)
|
||||
|
||||
// Cards
|
||||
CompactHandView(
|
||||
cards: playerCards,
|
||||
cardsFaceUp: playerCardsFaceUp,
|
||||
isWinner: playerIsWinner,
|
||||
screenWidth: screenWidth
|
||||
)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(localized: "Player hand"))
|
||||
.accessibilityValue(playerHandDescription + (playerIsWinner ? ", " + String(localized: "Winner") : ""))
|
||||
}
|
||||
|
||||
private var bankerHandSection: some View {
|
||||
VStack(spacing: Design.Spacing.small) {
|
||||
// Label with value
|
||||
HStack(spacing: Design.Spacing.small) {
|
||||
Text("BANKER")
|
||||
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if !bankerCards.isEmpty && bankerCardsFaceUp.contains(true) {
|
||||
HandValueBadge(value: bankerValue, color: .red)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: labelRowMinHeight)
|
||||
|
||||
// Cards
|
||||
CompactHandView(
|
||||
cards: bankerCards,
|
||||
cardsFaceUp: bankerCardsFaceUp,
|
||||
isWinner: bankerIsWinner,
|
||||
screenWidth: screenWidth
|
||||
)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(localized: "Banker hand"))
|
||||
.accessibilityValue(bankerHandDescription + (bankerIsWinner ? ", " + String(localized: "Winner") : ""))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Empty Hands") {
|
||||
ZStack {
|
||||
TableBackgroundView()
|
||||
CardsDisplayArea(
|
||||
playerCards: [],
|
||||
bankerCards: [],
|
||||
playerCardsFaceUp: [],
|
||||
bankerCardsFaceUp: [],
|
||||
playerValue: 0,
|
||||
bankerValue: 0,
|
||||
playerIsWinner: false,
|
||||
bankerIsWinner: false,
|
||||
isTie: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Player Wins") {
|
||||
ZStack {
|
||||
TableBackgroundView()
|
||||
CardsDisplayArea(
|
||||
playerCards: [
|
||||
Card(suit: .spades, rank: .king),
|
||||
Card(suit: .hearts, rank: .eight)
|
||||
],
|
||||
bankerCards: [
|
||||
Card(suit: .clubs, rank: .seven),
|
||||
Card(suit: .diamonds, rank: .five)
|
||||
],
|
||||
playerCardsFaceUp: [true, true],
|
||||
bankerCardsFaceUp: [true, true],
|
||||
playerValue: 8,
|
||||
bankerValue: 2,
|
||||
playerIsWinner: true,
|
||||
bankerIsWinner: false,
|
||||
isTie: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Banker Wins with 3 Cards") {
|
||||
ZStack {
|
||||
TableBackgroundView()
|
||||
CardsDisplayArea(
|
||||
playerCards: [
|
||||
Card(suit: .spades, rank: .four),
|
||||
Card(suit: .hearts, rank: .three),
|
||||
Card(suit: .clubs, rank: .two)
|
||||
],
|
||||
bankerCards: [
|
||||
Card(suit: .diamonds, rank: .ace),
|
||||
Card(suit: .spades, rank: .seven)
|
||||
],
|
||||
playerCardsFaceUp: [true, true, true],
|
||||
bankerCardsFaceUp: [true, true],
|
||||
playerValue: 9,
|
||||
bankerValue: 8,
|
||||
playerIsWinner: false,
|
||||
bankerIsWinner: true,
|
||||
isTie: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
202
Baccarat/Baccarat/Views/Table/CompactHandView.swift
Normal file
202
Baccarat/Baccarat/Views/Table/CompactHandView.swift
Normal file
@ -0,0 +1,202 @@
|
||||
//
|
||||
// CompactHandView.swift
|
||||
// Baccarat
|
||||
//
|
||||
// A compact view showing cards in a horizontal row with overlap.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// A compact hand view showing cards in a row with overlap.
|
||||
struct CompactHandView: View {
|
||||
let cards: [Card]
|
||||
let cardsFaceUp: [Bool]
|
||||
let isWinner: Bool
|
||||
/// Screen width passed from parent for responsive sizing
|
||||
var screenWidth: CGFloat = 400
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Whether we're on a large screen (iPad)
|
||||
private var isLargeScreen: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
/// Whether we're on a small screen (iPhone SE, etc)
|
||||
private var isSmallScreen: Bool {
|
||||
!isLargeScreen && screenWidth < Design.Size.smallScreenThreshold
|
||||
}
|
||||
|
||||
/// WIN badge font size - only scales on iPad
|
||||
private var winBadgeFontSize: CGFloat {
|
||||
let baseSize: CGFloat = 10
|
||||
return isLargeScreen ? baseSize * Design.Size.handScale * Design.Size.largeScreenMultiplier : baseSize
|
||||
}
|
||||
|
||||
/// Card width - responsive based on screen size
|
||||
private var cardWidth: CGFloat {
|
||||
if isLargeScreen {
|
||||
return Design.Size.cardWidthTableLarge
|
||||
} else if isSmallScreen {
|
||||
return Design.Size.cardWidthTableSmall
|
||||
} else {
|
||||
return Design.Size.cardWidthTable
|
||||
}
|
||||
}
|
||||
|
||||
/// Card height based on aspect ratio
|
||||
private var cardHeight: CGFloat {
|
||||
cardWidth * Design.Size.cardAspectRatio
|
||||
}
|
||||
|
||||
/// Card overlap - scaled with card size
|
||||
private var cardOverlap: CGFloat {
|
||||
if isLargeScreen {
|
||||
return Design.Size.cardOverlapLarge
|
||||
} else if isSmallScreen {
|
||||
return Design.Size.cardOverlapSmall
|
||||
} else {
|
||||
return Design.Size.cardOverlap
|
||||
}
|
||||
}
|
||||
|
||||
private let placeholderSpacing: CGFloat = Design.Spacing.small
|
||||
|
||||
/// Fixed container width to prevent resizing during deal
|
||||
private var fixedContainerWidth: CGFloat {
|
||||
// Max 3 cards: first card full width + 2 more with overlap
|
||||
let cardsWidth = cardWidth + (cardWidth + cardOverlap) * 2
|
||||
return cardsWidth + Design.Spacing.xSmall * 2
|
||||
}
|
||||
|
||||
/// Fixed container height
|
||||
private var fixedContainerHeight: CGFloat {
|
||||
cardHeight + Design.Spacing.xSmall * 2
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Fixed-size container
|
||||
Color.clear
|
||||
.frame(width: fixedContainerWidth, height: fixedContainerHeight)
|
||||
|
||||
// Cards content centered in fixed container
|
||||
cardsContent
|
||||
}
|
||||
.background(winnerBorder)
|
||||
.overlay(alignment: .bottom) {
|
||||
winBadge
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Views
|
||||
|
||||
private var cardsContent: some View {
|
||||
HStack(spacing: cards.isEmpty ? placeholderSpacing : cardOverlap) {
|
||||
if cards.isEmpty {
|
||||
// Placeholders - no overlap, just side by side
|
||||
ForEach(0..<2, id: \.self) { _ in
|
||||
CardPlaceholderView(width: cardWidth)
|
||||
}
|
||||
} else {
|
||||
ForEach(cards.indices, id: \.self) { index in
|
||||
let isFaceUp = index < cardsFaceUp.count ? cardsFaceUp[index] : false
|
||||
CardView(
|
||||
card: cards[index],
|
||||
isFaceUp: isFaceUp,
|
||||
cardWidth: cardWidth
|
||||
)
|
||||
.zIndex(Double(index))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var winnerBorder: some View {
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
|
||||
.strokeBorder(
|
||||
isWinner ? Color.yellow : Color.clear,
|
||||
lineWidth: Design.LineWidth.standard
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var winBadge: some View {
|
||||
if isWinner {
|
||||
Text("WIN")
|
||||
.font(.system(size: winBadgeFontSize, weight: .black))
|
||||
.foregroundStyle(.black)
|
||||
.padding(.horizontal, Design.Spacing.small)
|
||||
.padding(.vertical, Design.Spacing.xxSmall)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.yellow)
|
||||
)
|
||||
.offset(y: Design.Spacing.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Empty Hand") {
|
||||
ZStack {
|
||||
TableBackgroundView()
|
||||
CompactHandView(
|
||||
cards: [],
|
||||
cardsFaceUp: [],
|
||||
isWinner: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Two Cards") {
|
||||
ZStack {
|
||||
TableBackgroundView()
|
||||
CompactHandView(
|
||||
cards: [
|
||||
Card(suit: .spades, rank: .king),
|
||||
Card(suit: .hearts, rank: .eight)
|
||||
],
|
||||
cardsFaceUp: [true, true],
|
||||
isWinner: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Three Cards - Winner") {
|
||||
ZStack {
|
||||
TableBackgroundView()
|
||||
CompactHandView(
|
||||
cards: [
|
||||
Card(suit: .spades, rank: .four),
|
||||
Card(suit: .hearts, rank: .three),
|
||||
Card(suit: .clubs, rank: .two)
|
||||
],
|
||||
cardsFaceUp: [true, true, true],
|
||||
isWinner: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Cards Face Down") {
|
||||
ZStack {
|
||||
TableBackgroundView()
|
||||
CompactHandView(
|
||||
cards: [
|
||||
Card(suit: .diamonds, rank: .ace),
|
||||
Card(suit: .spades, rank: .seven)
|
||||
],
|
||||
cardsFaceUp: [false, false],
|
||||
isWinner: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
81
Baccarat/Baccarat/Views/Table/HandValueBadge.swift
Normal file
81
Baccarat/Baccarat/Views/Table/HandValueBadge.swift
Normal file
@ -0,0 +1,81 @@
|
||||
//
|
||||
// HandValueBadge.swift
|
||||
// Baccarat
|
||||
//
|
||||
// A circular badge displaying the hand value.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// A small circular badge showing the hand value.
|
||||
struct HandValueBadge: View {
|
||||
let value: Int
|
||||
let color: Color
|
||||
|
||||
// MARK: - Environment
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Whether we're on a large screen (iPad)
|
||||
private var isLargeScreen: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
/// Scale factor for badge sizing - only applies on iPad to avoid clipping on iPhone
|
||||
private var scale: CGFloat {
|
||||
isLargeScreen ? Design.Size.handScale * Design.Size.largeScreenMultiplier : 1.0
|
||||
}
|
||||
|
||||
@ScaledMetric(relativeTo: .headline) private var baseValueFontSize: CGFloat = 15
|
||||
@ScaledMetric(relativeTo: .headline) private var baseBadgeSize: CGFloat = 26
|
||||
|
||||
private var valueFontSize: CGFloat { baseValueFontSize * scale }
|
||||
private var badgeSize: CGFloat { baseBadgeSize * scale }
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Text("\(value)")
|
||||
.font(.system(size: valueFontSize, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: badgeSize, height: badgeSize)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(color)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Player Value (Blue)") {
|
||||
ZStack {
|
||||
Color.Table.backgroundDark
|
||||
HandValueBadge(value: 8, color: .blue)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Banker Value (Red)") {
|
||||
ZStack {
|
||||
Color.Table.backgroundDark
|
||||
HandValueBadge(value: 5, color: .red)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Natural 9") {
|
||||
ZStack {
|
||||
Color.Table.backgroundDark
|
||||
HandValueBadge(value: 9, color: .blue)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Zero Value") {
|
||||
ZStack {
|
||||
Color.Table.backgroundDark
|
||||
HandValueBadge(value: 0, color: .red)
|
||||
}
|
||||
}
|
||||
|
||||
169
Baccarat/Baccarat/Views/Table/RoadMapGridView.swift
Normal file
169
Baccarat/Baccarat/Views/Table/RoadMapGridView.swift
Normal file
@ -0,0 +1,169 @@
|
||||
//
|
||||
// RoadMapGridView.swift
|
||||
// Baccarat
|
||||
//
|
||||
// A grid-based road map for landscape mode sidebar.
|
||||
// Reads columns top-to-bottom, left-to-right (traditional baccarat style).
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CasinoKit
|
||||
|
||||
/// A grid-based road map for the landscape sidebar.
|
||||
/// Results fill columns from top to bottom, then move right.
|
||||
/// Rows are calculated dynamically based on available height.
|
||||
struct RoadMapGridView: View {
|
||||
let results: [RoundResult]
|
||||
|
||||
/// Size of each dot
|
||||
var dotSize: CGFloat = 32
|
||||
|
||||
/// Spacing between dots
|
||||
var spacing: CGFloat = 10
|
||||
|
||||
/// Calculate number of rows that fit in given height
|
||||
private func rowCount(for height: CGFloat) -> Int {
|
||||
let availableHeight = height - (spacing * 2) // Account for padding
|
||||
let cellHeight = dotSize + spacing
|
||||
let count = Int(availableHeight / cellHeight)
|
||||
return max(count, 1) // At least 1 row
|
||||
}
|
||||
|
||||
/// Arrange results into columns based on row count
|
||||
private func columns(rows: Int) -> [[RoundResult]] {
|
||||
guard rows > 0 else { return [] }
|
||||
|
||||
var cols: [[RoundResult]] = []
|
||||
var currentCol: [RoundResult] = []
|
||||
|
||||
for result in results {
|
||||
currentCol.append(result)
|
||||
if currentCol.count >= rows {
|
||||
cols.append(currentCol)
|
||||
currentCol = []
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining items as last column
|
||||
if !currentCol.isEmpty {
|
||||
cols.append(currentCol)
|
||||
}
|
||||
|
||||
return cols
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let rows = rowCount(for: geometry.size.height)
|
||||
let cols = columns(rows: rows)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: spacing) {
|
||||
ForEach(Array(cols.enumerated()), id: \.offset) { colIndex, column in
|
||||
VStack(spacing: spacing) {
|
||||
ForEach(Array(column.enumerated()), id: \.offset) { rowIndex, result in
|
||||
GridDot(
|
||||
result: result.result,
|
||||
size: dotSize,
|
||||
hasPair: result.hasPair,
|
||||
isNatural: result.isNatural
|
||||
)
|
||||
}
|
||||
|
||||
// Fill remaining rows with empty space for alignment
|
||||
if column.count < rows {
|
||||
ForEach(0..<(rows - column.count), id: \.self) { _ in
|
||||
Color.clear
|
||||
.frame(width: dotSize, height: dotSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(spacing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact dot for the grid display
|
||||
private struct GridDot: View {
|
||||
let result: GameResult
|
||||
let size: CGFloat
|
||||
var hasPair: Bool = false
|
||||
var isNatural: Bool = false
|
||||
|
||||
private var color: Color {
|
||||
switch result {
|
||||
case .playerWins: return .blue
|
||||
case .bankerWins: return .red
|
||||
case .tie: return .green
|
||||
}
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
switch result {
|
||||
case .playerWins: return "P"
|
||||
case .bankerWins: return "B"
|
||||
case .tie: return "T"
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: size, height: size)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: size * 0.5, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
// Pair indicator (small yellow dot)
|
||||
if hasPair {
|
||||
Circle()
|
||||
.fill(Color.yellow)
|
||||
.frame(width: size * 0.25, height: size * 0.25)
|
||||
.offset(x: -size * 0.3, y: size * 0.3)
|
||||
}
|
||||
|
||||
// Natural indicator (star)
|
||||
if isNatural {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: size * 0.25))
|
||||
.foregroundStyle(.yellow)
|
||||
.offset(x: size * 0.3, y: -size * 0.3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Grid Road Map") {
|
||||
ZStack {
|
||||
Color.Table.preview
|
||||
.ignoresSafeArea()
|
||||
|
||||
RoadMapGridView(
|
||||
results: [
|
||||
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 6, playerPair: true),
|
||||
RoundResult(result: .playerWins, playerValue: 7, bankerValue: 5),
|
||||
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 7),
|
||||
RoundResult(result: .bankerWins, playerValue: 3, bankerValue: 8),
|
||||
RoundResult(result: .bankerWins, playerValue: 2, bankerValue: 9, bankerPair: true),
|
||||
RoundResult(result: .tie, playerValue: 5, bankerValue: 5),
|
||||
RoundResult(result: .playerWins, playerValue: 9, bankerValue: 3),
|
||||
RoundResult(result: .playerWins, playerValue: 8, bankerValue: 2),
|
||||
RoundResult(result: .bankerWins, playerValue: 4, bankerValue: 6),
|
||||
RoundResult(result: .playerWins, playerValue: 7, bankerValue: 4, playerPair: true),
|
||||
RoundResult(result: .tie, playerValue: 6, bankerValue: 6),
|
||||
RoundResult(result: .bankerWins, playerValue: 1, bankerValue: 8)
|
||||
],
|
||||
dotSize: 32
|
||||
)
|
||||
.frame(width: 240, height: 400)
|
||||
.background(Color.black.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user