Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
6ae84c02eb
commit
d2b9688b30
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user