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

This commit is contained in:
Matt Bruce 2025-12-17 13:41:37 -06:00
parent 6ae84c02eb
commit d2b9688b30
4 changed files with 142 additions and 53 deletions

View File

@ -83,6 +83,11 @@ enum Design {
static let topBetRowHeight: CGFloat = 52
static let mainBetRowHeight: CGFloat = 65
static let bonusZoneWidth: CGFloat = 80
// iPad max widths
static let maxContentWidthPortrait: CGFloat = 500
static let maxContentWidthLandscape: CGFloat = 800
static let maxModalWidth: CGFloat = 450
}
// MARK: - Animation

View File

@ -17,6 +17,24 @@ struct GameTableView: View {
@State private var showRules = false
@State private var showStats = false
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
/// 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
}
/// 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)
}
@ -53,7 +71,7 @@ struct GameTableView: View {
Spacer(minLength: Design.Spacing.xSmall)
// Cards display area
// Cards display area - constrained width on iPad
CardsDisplayArea(
playerCards: state.visiblePlayerCards,
bankerCards: state.visibleBankerCards,
@ -65,36 +83,40 @@ struct GameTableView: View {
bankerIsWinner: bankerIsWinner,
isTie: isTie
)
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
Spacer(minLength: Design.Spacing.xSmall)
// Road map history
// Road map history - constrained width on iPad
if settings.showHistory && !state.roundHistory.isEmpty {
RoadMapView(results: state.recentResults)
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
.padding(.horizontal)
}
Spacer(minLength: Design.Spacing.small)
// Mini Baccarat betting table
// Mini Baccarat betting table - constrained width on iPad
MiniBaccaratTableView(
gameState: state,
selectedChip: selectedChip
)
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
.padding(.horizontal, Design.Spacing.medium)
Spacer(minLength: Design.Spacing.medium)
// Chip selector - shows higher chips as you win more!
// Chip selector - constrained width on iPad
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
maxBet: state.maxBet
)
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
Spacer(minLength: Design.Spacing.small)
// Action buttons
// Action buttons - constrained width on iPad
ActionButtonsView(
gameState: state,
onDeal: {
@ -105,12 +127,13 @@ struct GameTableView: View {
onClear: { state.clearBets() },
onNewRound: { state.newRound() }
)
.frame(maxWidth: isLargeScreen ? maxContentWidth * 0.8 : .infinity)
.padding(.horizontal)
.padding(.bottom, Design.Spacing.xSmall)
}
.safeAreaPadding(.bottom)
// Result banner overlay
// Result banner overlay (handles its own iPad sizing)
if state.showResultBanner, let result = state.lastResult {
ResultBannerView(
result: result,
@ -134,7 +157,7 @@ struct GameTableView: View {
}
}
// Game Over overlay when broke
// Game Over overlay (handles its own iPad sizing)
if state.balance == 0 && state.currentBets.isEmpty && !state.isAnimating {
GameOverView(
roundsPlayed: state.roundHistory.count,
@ -171,6 +194,12 @@ struct GameOverView: View {
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)
@ -290,6 +319,7 @@ struct GameOverView: View {
)
)
.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)
@ -434,29 +464,50 @@ struct CompactHandView: View {
// Fixed size: cards have strict visual constraints
private let cardWidth: CGFloat = 45
private let cardHeight: CGFloat = 63 // Standard card aspect ratio ~1.4
private let cardOverlap: CGFloat = -12
private let placeholderSpacing: CGFloat = 8
/// Fixed container width to prevent resizing during deal
/// Calculated as: 3 cards with overlap + padding
/// = cardWidth + (cardWidth + overlap) * 2 + padding * 2
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
}
var body: 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))
ZStack {
// Fixed-size container
Color.clear
.frame(width: fixedContainerWidth, height: fixedContainerHeight)
// Cards content centered in fixed container
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))
}
}
}
}
.padding(Design.Spacing.xSmall)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.strokeBorder(
@ -692,33 +743,45 @@ struct ActionButtonsView: View {
private let iconSize: CGFloat = Design.BaseFontSize.xxLarge + Design.Spacing.xSmall
private let statusFontSize: CGFloat = Design.BaseFontSize.medium
/// Fixed height to prevent layout shifts when buttons change
private let containerHeight: CGFloat = 50
var body: some View {
HStack(spacing: Design.Spacing.medium) {
if gameState.currentPhase == .betting {
// Clear bets button - icon only at accessibility sizes
clearButton
// Deal button - icon only at accessibility sizes
dealButton
} else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner {
// New round button - only shown after banner is dismissed
// (The banner itself has a New Round button)
newRoundButton
} else if !gameState.showResultBanner {
// Playing indicator
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)
ZStack {
// Fixed height container to prevent layout shifts
Color.clear
.frame(height: containerHeight)
// Content changes with animation
Group {
if gameState.currentPhase == .betting {
// Clear bets button - icon only at accessibility sizes
HStack(spacing: Design.Spacing.medium) {
clearButton
dealButton
}
} else if gameState.currentPhase == .roundComplete && !gameState.showResultBanner {
// New round button - only shown after banner is dismissed
// (The banner itself has a New Round button)
newRoundButton
} else if !gameState.showResultBanner {
// Playing indicator
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)
}
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.medium)
}
.animation(.easeInOut(duration: Design.Animation.quick), value: gameState.currentPhase)
.animation(.easeInOut(duration: Design.Animation.quick), value: gameState.showResultBanner)
}
}

View File

@ -31,6 +31,13 @@ struct ResultBannerView: View {
@State private var showTotal = false
@State private var showButton = false
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
/// Maximum width for the banner card on iPad
private var maxBannerWidth: CGFloat {
horizontalSizeClass == .regular ? Design.Size.maxModalWidth : .infinity
}
// MARK: - Scaled Font Sizes (Dynamic Type)
@ScaledMetric(relativeTo: .largeTitle) private var resultFontSize: CGFloat = Design.BaseFontSize.largeTitle
@ -185,6 +192,7 @@ struct ResultBannerView: View {
}
.padding(.horizontal, Design.Spacing.xLarge)
.padding(.vertical, Design.Spacing.xxLarge)
.frame(maxWidth: maxBannerWidth)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.xxLarge)
.fill(
@ -213,6 +221,7 @@ struct ResultBannerView: View {
)
)
.shadow(color: result.color.opacity(Design.Opacity.light), radius: Design.Shadow.radiusXXLarge)
.padding(.horizontal, Design.Spacing.large)
.scaleEffect(showBanner ? Design.Scale.normal : Design.Scale.slightShrink)
.opacity(showBanner ? Design.Scale.normal : 0)
}
@ -395,6 +404,8 @@ private struct PairBadge: View {
/// Confetti particle for celebrations.
struct ConfettiPiece: View {
let color: Color
let containerSize: CGSize
@State private var position: CGPoint = .zero
@State private var rotation: Double = 0
@State private var opacity: Double = 1
@ -410,14 +421,13 @@ struct ConfettiPiece: View {
.position(position)
.opacity(opacity)
.onAppear {
let screenWidth = 400.0
let startX = Double.random(in: 0...screenWidth)
let startX = Double.random(in: 0...containerSize.width)
position = CGPoint(x: startX, y: -20)
withAnimation(.easeIn(duration: Double.random(in: 2...4))) {
position = CGPoint(
x: startX + Double.random(in: -100...100),
y: 800
y: containerSize.height + 50
)
rotation = Double.random(in: 360...1080)
opacity = 0
@ -431,11 +441,17 @@ struct ConfettiView: View {
let colors: [Color] = [.red, .blue, .green, .yellow, .orange, .purple, .pink]
var body: some View {
ZStack {
ForEach(0..<50, id: \.self) { _ in
ConfettiPiece(color: colors.randomElement() ?? .yellow)
GeometryReader { geometry in
ZStack {
ForEach(0..<50, id: \.self) { _ in
ConfettiPiece(
color: colors.randomElement() ?? .yellow,
containerSize: geometry.size
)
}
}
}
.ignoresSafeArea()
.allowsHitTesting(false)
.accessibilityHidden(true)
}

View File

@ -134,6 +134,7 @@ struct SettingsView: View {
}
}
.tint(.yellow)
.padding(.vertical, Design.Spacing.xSmall)
if gameState.iCloudEnabled {
Divider()
@ -186,6 +187,7 @@ struct SettingsView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
}
@ -380,6 +382,7 @@ struct SettingsToggle: View {
}
}
.tint(.yellow)
.padding(.vertical, Design.Spacing.xSmall)
}
}
@ -418,6 +421,7 @@ struct SpeedPicker: View {
}
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
}
@ -515,6 +519,7 @@ struct VolumePicker: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
}