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.",
|
"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
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"↓ then →" : {
|
||||||
|
"comment" : "A textual instruction for using the road map in the game.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"+%lld" : {
|
"+%lld" : {
|
||||||
"comment" : "A text element displaying the total winnings in the round, prefixed by a plus sign. The argument is the total winnings amount.",
|
"comment" : "A text element displaying the total winnings in the round, prefixed by a plus sign. The argument is the total winnings amount.",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -166,6 +170,7 @@
|
|||||||
},
|
},
|
||||||
"$" : {
|
"$" : {
|
||||||
"comment" : "The currency symbol \"$\".",
|
"comment" : "The currency symbol \"$\".",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -207,6 +212,7 @@
|
|||||||
},
|
},
|
||||||
"$%@" : {
|
"$%@" : {
|
||||||
"comment" : "The value of the balance displayed in the top bar.",
|
"comment" : "The value of the balance displayed in the top bar.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -677,6 +683,7 @@
|
|||||||
},
|
},
|
||||||
"Balance" : {
|
"Balance" : {
|
||||||
"comment" : "A label describing the user's current balance.",
|
"comment" : "A label describing the user's current balance.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -1215,6 +1222,7 @@
|
|||||||
},
|
},
|
||||||
"Cards remaining in shoe" : {
|
"Cards remaining in shoe" : {
|
||||||
"comment" : "A label describing the number of cards remaining in the shoe.",
|
"comment" : "A label describing the number of cards remaining in the shoe.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -2123,6 +2131,7 @@
|
|||||||
},
|
},
|
||||||
"Help" : {
|
"Help" : {
|
||||||
"comment" : "The label of a button that shows help information.",
|
"comment" : "The label of a button that shows help information.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -3423,6 +3432,7 @@
|
|||||||
},
|
},
|
||||||
"Reset" : {
|
"Reset" : {
|
||||||
"comment" : "A button that resets the game to its initial state.",
|
"comment" : "A button that resets the game to its initial state.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -4453,6 +4463,7 @@
|
|||||||
},
|
},
|
||||||
"View detailed game statistics" : {
|
"View detailed game statistics" : {
|
||||||
"comment" : "A hint that appears when hovering over the \"Statistics\" button, explaining its function.",
|
"comment" : "A hint that appears when hovering over the \"Statistics\" button, explaining its function.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
@ -13,6 +13,9 @@ import CasinoKit
|
|||||||
/// Shared constants are imported from CasinoDesign; game-specific values are defined here.
|
/// Shared constants are imported from CasinoDesign; game-specific values are defined here.
|
||||||
enum Design {
|
enum Design {
|
||||||
|
|
||||||
|
/// Set to true to show layout debug borders on views
|
||||||
|
static let showDebugBorders = false
|
||||||
|
|
||||||
// MARK: - Shared Constants (from CasinoKit)
|
// MARK: - Shared Constants (from CasinoKit)
|
||||||
|
|
||||||
typealias Spacing = CasinoDesign.Spacing
|
typealias Spacing = CasinoDesign.Spacing
|
||||||
@ -34,7 +37,7 @@ enum Design {
|
|||||||
/// Hand scaling factor for cards and related elements.
|
/// Hand scaling factor for cards and related elements.
|
||||||
/// 1.0 = original size, 1.5 = 50% larger, 2.0 = double size.
|
/// 1.0 = original size, 1.5 = 50% larger, 2.0 = double size.
|
||||||
/// Adjust this value to change card sizes across the app.
|
/// 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).
|
/// Scale multiplier for small screens (iPhone SE, etc).
|
||||||
/// Applied instead of handScale on screens narrower than smallScreenThreshold.
|
/// Applied instead of handScale on screens narrower than smallScreenThreshold.
|
||||||
@ -56,11 +59,16 @@ enum Design {
|
|||||||
/// Base card width before scaling (for reference)
|
/// Base card width before scaling (for reference)
|
||||||
private static let cardWidthTableBase: CGFloat = 45
|
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)
|
/// 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
|
/// 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)
|
// Baccarat table cards - scaled for better visibility (standard iPhone)
|
||||||
static let cardWidthTable: CGFloat = cardWidthTableBase * handScale
|
static let cardWidthTable: CGFloat = cardWidthTableBase * handScale
|
||||||
@ -72,7 +80,7 @@ enum Design {
|
|||||||
static let cardWidthTableLarge: CGFloat = cardWidthTableBase * handScale * largeScreenMultiplier
|
static let cardWidthTableLarge: CGFloat = cardWidthTableBase * handScale * largeScreenMultiplier
|
||||||
|
|
||||||
/// Card overlap for large screens
|
/// 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
|
// Chips - use CasinoDesign values
|
||||||
static let chipSmall: CGFloat = CasinoDesign.Size.chipSmall
|
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
|
// Baccarat
|
||||||
//
|
//
|
||||||
// A modern baccarat table layout with all betting options.
|
// The baccarat betting table layout with main bets and side bets.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CasinoKit
|
import CasinoKit
|
||||||
|
|
||||||
/// The baccarat betting table layout with main bets and side bets.
|
/// The baccarat betting table layout with main bets and side bets.
|
||||||
struct MiniBaccaratTableView: View {
|
struct BettingTableView: View {
|
||||||
@Bindable var gameState: GameState
|
@Bindable var gameState: GameState
|
||||||
let selectedChip: ChipDenomination
|
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
|
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
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
private func betAmount(for type: BetType) -> Int {
|
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
|
// MARK: - Body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -60,6 +82,7 @@ struct MiniBaccaratTableView: View {
|
|||||||
.tracking(1)
|
.tracking(1)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
|
||||||
|
.debugBorder(showDebugBorders, color: .gray, label: "Limits")
|
||||||
|
|
||||||
// Main betting table
|
// Main betting table
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@ -74,10 +97,12 @@ struct MiniBaccaratTableView: View {
|
|||||||
isBankerPairAtMax: isAtMax(for: .bankerPair),
|
isBankerPairAtMax: isAtMax(for: .bankerPair),
|
||||||
isTieAtMax: isAtMax(for: .tie),
|
isTieAtMax: isAtMax(for: .tie),
|
||||||
isPlayerPairAtMax: isAtMax(for: .playerPair),
|
isPlayerPairAtMax: isAtMax(for: .playerPair),
|
||||||
|
rowHeight: topRowHeight,
|
||||||
onBankerPair: { gameState.placeBet(type: .bankerPair, amount: selectedChip.rawValue) },
|
onBankerPair: { gameState.placeBet(type: .bankerPair, amount: selectedChip.rawValue) },
|
||||||
onTie: { gameState.placeBet(type: .tie, amount: selectedChip.rawValue) },
|
onTie: { gameState.placeBet(type: .tie, amount: selectedChip.rawValue) },
|
||||||
onPlayerPair: { gameState.placeBet(type: .playerPair, amount: selectedChip.rawValue) }
|
onPlayerPair: { gameState.placeBet(type: .playerPair, amount: selectedChip.rawValue) }
|
||||||
)
|
)
|
||||||
|
.debugBorder(showDebugBorders, color: .purple, label: "TopRow")
|
||||||
|
|
||||||
// Divider
|
// Divider
|
||||||
Rectangle()
|
Rectangle()
|
||||||
@ -96,9 +121,11 @@ struct MiniBaccaratTableView: View {
|
|||||||
isMainAtMax: isAtMax(for: .banker),
|
isMainAtMax: isAtMax(for: .banker),
|
||||||
isBonusAtMax: isAtMax(for: .dragonBonusBanker),
|
isBonusAtMax: isAtMax(for: .dragonBonusBanker),
|
||||||
mainColor: Color.BettingZone.bankerDark,
|
mainColor: Color.BettingZone.bankerDark,
|
||||||
|
rowHeight: mainRowHeight,
|
||||||
onMain: { gameState.placeBet(type: .banker, amount: selectedChip.rawValue) },
|
onMain: { gameState.placeBet(type: .banker, amount: selectedChip.rawValue) },
|
||||||
onBonus: { gameState.placeBet(type: .dragonBonusBanker, amount: selectedChip.rawValue) }
|
onBonus: { gameState.placeBet(type: .dragonBonusBanker, amount: selectedChip.rawValue) }
|
||||||
)
|
)
|
||||||
|
.debugBorder(showDebugBorders, color: .red, label: "BankerRow")
|
||||||
|
|
||||||
// Divider
|
// Divider
|
||||||
Rectangle()
|
Rectangle()
|
||||||
@ -117,9 +144,11 @@ struct MiniBaccaratTableView: View {
|
|||||||
isMainAtMax: isAtMax(for: .player),
|
isMainAtMax: isAtMax(for: .player),
|
||||||
isBonusAtMax: isAtMax(for: .dragonBonusPlayer),
|
isBonusAtMax: isAtMax(for: .dragonBonusPlayer),
|
||||||
mainColor: Color.BettingZone.playerDark,
|
mainColor: Color.BettingZone.playerDark,
|
||||||
|
rowHeight: mainRowHeight,
|
||||||
onMain: { gameState.placeBet(type: .player, amount: selectedChip.rawValue) },
|
onMain: { gameState.placeBet(type: .player, amount: selectedChip.rawValue) },
|
||||||
onBonus: { gameState.placeBet(type: .dragonBonusPlayer, amount: selectedChip.rawValue) }
|
onBonus: { gameState.placeBet(type: .dragonBonusPlayer, amount: selectedChip.rawValue) }
|
||||||
)
|
)
|
||||||
|
.debugBorder(showDebugBorders, color: .blue, label: "PlayerRow")
|
||||||
}
|
}
|
||||||
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large))
|
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large))
|
||||||
.overlay(
|
.overlay(
|
||||||
@ -135,6 +164,7 @@ struct MiniBaccaratTableView: View {
|
|||||||
)
|
)
|
||||||
.shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge)
|
.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 isBankerPairAtMax: Bool
|
||||||
let isTieAtMax: Bool
|
let isTieAtMax: Bool
|
||||||
let isPlayerPairAtMax: Bool
|
let isPlayerPairAtMax: Bool
|
||||||
|
let rowHeight: CGFloat
|
||||||
let onBankerPair: () -> Void
|
let onBankerPair: () -> Void
|
||||||
let onTie: () -> Void
|
let onTie: () -> Void
|
||||||
let onPlayerPair: () -> Void
|
let onPlayerPair: () -> Void
|
||||||
@ -194,7 +225,7 @@ private struct TopBettingRow: View {
|
|||||||
action: onPlayerPair
|
action: onPlayerPair
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(height: Design.Size.topBetRowHeight)
|
.frame(height: rowHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -304,6 +335,7 @@ private struct MainBetRow: View {
|
|||||||
let isMainAtMax: Bool
|
let isMainAtMax: Bool
|
||||||
let isBonusAtMax: Bool
|
let isBonusAtMax: Bool
|
||||||
let mainColor: Color
|
let mainColor: Color
|
||||||
|
let rowHeight: CGFloat
|
||||||
let onMain: () -> Void
|
let onMain: () -> Void
|
||||||
let onBonus: () -> Void
|
let onBonus: () -> Void
|
||||||
|
|
||||||
@ -335,7 +367,7 @@ private struct MainBetRow: View {
|
|||||||
)
|
)
|
||||||
.frame(width: Design.Size.bonusZoneWidth)
|
.frame(width: Design.Size.bonusZoneWidth)
|
||||||
}
|
}
|
||||||
.frame(height: Design.Size.mainBetRowHeight)
|
.frame(height: rowHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -528,13 +560,31 @@ private struct ChipBadge: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
// MARK: - Previews
|
||||||
ZStack {
|
|
||||||
Color.Table.baseDark
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
MiniBaccaratTableView(
|
#Preview("Betting Table") {
|
||||||
gameState: GameState(),
|
ZStack {
|
||||||
|
TableBackgroundView()
|
||||||
|
|
||||||
|
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
|
selectedChip: .hundred
|
||||||
)
|
)
|
||||||
.padding()
|
.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