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

This commit is contained in:
Matt Bruce 2025-12-22 14:33:16 -06:00
parent cac0af4ab3
commit a0ac5a6e64
18 changed files with 1556 additions and 1018 deletions

View File

@ -120,6 +120,10 @@
"comment" : "A bullet point in the \"How to Export Icons\" section, describing how to use an online tool to generate all sizes for an app icon.",
"isCommentAutoGenerated" : true
},
"↓ then →" : {
"comment" : "A textual instruction for using the road map in the game.",
"isCommentAutoGenerated" : true
},
"+%lld" : {
"comment" : "A text element displaying the total winnings in the round, prefixed by a plus sign. The argument is the total winnings amount.",
"localizations" : {
@ -166,6 +170,7 @@
},
"$" : {
"comment" : "The currency symbol \"$\".",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -207,6 +212,7 @@
},
"$%@" : {
"comment" : "The value of the balance displayed in the top bar.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -677,6 +683,7 @@
},
"Balance" : {
"comment" : "A label describing the user's current balance.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -1215,6 +1222,7 @@
},
"Cards remaining in shoe" : {
"comment" : "A label describing the number of cards remaining in the shoe.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -2123,6 +2131,7 @@
},
"Help" : {
"comment" : "The label of a button that shows help information.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -3423,6 +3432,7 @@
},
"Reset" : {
"comment" : "A button that resets the game to its initial state.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@ -4453,6 +4463,7 @@
},
"View detailed game statistics" : {
"comment" : "A hint that appears when hovering over the \"Statistics\" button, explaining its function.",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {

View File

@ -13,6 +13,9 @@ import CasinoKit
/// Shared constants are imported from CasinoDesign; game-specific values are defined here.
enum Design {
/// Set to true to show layout debug borders on views
static let showDebugBorders = false
// MARK: - Shared Constants (from CasinoKit)
typealias Spacing = CasinoDesign.Spacing
@ -34,7 +37,7 @@ enum Design {
/// Hand scaling factor for cards and related elements.
/// 1.0 = original size, 1.5 = 50% larger, 2.0 = double size.
/// Adjust this value to change card sizes across the app.
static let handScale: CGFloat = 1.5
static let handScale: CGFloat = 1.75
/// Scale multiplier for small screens (iPhone SE, etc).
/// Applied instead of handScale on screens narrower than smallScreenThreshold.
@ -56,11 +59,16 @@ enum Design {
/// Base card width before scaling (for reference)
private static let cardWidthTableBase: CGFloat = 45
/// Base card overlap before scaling.
/// More negative = more overlap (less card visible).
/// -15 is default, -20 shows less card, -25 shows even less.
private static let cardOverlapBase: CGFloat = -25
/// Card overlap scaled with hand size (standard iPhone)
static let cardOverlap: CGFloat = CasinoDesign.Size.cardOverlap * handScale
static let cardOverlap: CGFloat = cardOverlapBase * handScale
/// Card overlap for small screens
static let cardOverlapSmall: CGFloat = CasinoDesign.Size.cardOverlap * smallScreenScale
static let cardOverlapSmall: CGFloat = cardOverlapBase * smallScreenScale
// Baccarat table cards - scaled for better visibility (standard iPhone)
static let cardWidthTable: CGFloat = cardWidthTableBase * handScale
@ -72,7 +80,7 @@ enum Design {
static let cardWidthTableLarge: CGFloat = cardWidthTableBase * handScale * largeScreenMultiplier
/// Card overlap for large screens
static let cardOverlapLarge: CGFloat = CasinoDesign.Size.cardOverlap * handScale * largeScreenMultiplier
static let cardOverlapLarge: CGFloat = cardOverlapBase * handScale * largeScreenMultiplier
// Chips - use CasinoDesign values
static let chipSmall: CGFloat = CasinoDesign.Size.chipSmall

View 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()
}
}
}

View 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

View 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: {}
)
}

View File

@ -1,22 +1,41 @@
//
// MiniBaccaratTableView.swift
// BettingTableView.swift
// Baccarat
//
// A modern baccarat table layout with all betting options.
// The baccarat betting table layout with main bets and side bets.
//
import SwiftUI
import CasinoKit
/// The baccarat betting table layout with main bets and side bets.
struct MiniBaccaratTableView: View {
struct BettingTableView: View {
@Bindable var gameState: GameState
let selectedChip: ChipDenomination
// MARK: - Fixed Font Sizes
// MARK: - Environment
@Environment(\.verticalSizeClass) private var verticalSizeClass
/// Whether we're in landscape mode (compact vertical)
private var isLandscape: Bool {
verticalSizeClass == .compact
}
// MARK: - Adaptive Sizes
private let tableLimitsFontSize: CGFloat = Design.BaseFontSize.small
/// Top bet row height - shorter in landscape
private var topRowHeight: CGFloat {
isLandscape ? 40 : Design.Size.topBetRowHeight
}
/// Main bet row height - shorter in landscape
private var mainRowHeight: CGFloat {
isLandscape ? 50 : Design.Size.mainBetRowHeight
}
// MARK: - Computed Properties
private func betAmount(for type: BetType) -> Int {
@ -49,6 +68,9 @@ struct MiniBaccaratTableView: View {
)
}
// Use global debug flag from Design constants
private var showDebugBorders: Bool { Design.showDebugBorders }
// MARK: - Body
var body: some View {
@ -60,6 +82,7 @@ struct MiniBaccaratTableView: View {
.tracking(1)
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.comfortable)
.debugBorder(showDebugBorders, color: .gray, label: "Limits")
// Main betting table
VStack(spacing: 0) {
@ -74,10 +97,12 @@ struct MiniBaccaratTableView: View {
isBankerPairAtMax: isAtMax(for: .bankerPair),
isTieAtMax: isAtMax(for: .tie),
isPlayerPairAtMax: isAtMax(for: .playerPair),
rowHeight: topRowHeight,
onBankerPair: { gameState.placeBet(type: .bankerPair, amount: selectedChip.rawValue) },
onTie: { gameState.placeBet(type: .tie, amount: selectedChip.rawValue) },
onPlayerPair: { gameState.placeBet(type: .playerPair, amount: selectedChip.rawValue) }
)
.debugBorder(showDebugBorders, color: .purple, label: "TopRow")
// Divider
Rectangle()
@ -96,9 +121,11 @@ struct MiniBaccaratTableView: View {
isMainAtMax: isAtMax(for: .banker),
isBonusAtMax: isAtMax(for: .dragonBonusBanker),
mainColor: Color.BettingZone.bankerDark,
rowHeight: mainRowHeight,
onMain: { gameState.placeBet(type: .banker, amount: selectedChip.rawValue) },
onBonus: { gameState.placeBet(type: .dragonBonusBanker, amount: selectedChip.rawValue) }
)
.debugBorder(showDebugBorders, color: .red, label: "BankerRow")
// Divider
Rectangle()
@ -117,9 +144,11 @@ struct MiniBaccaratTableView: View {
isMainAtMax: isAtMax(for: .player),
isBonusAtMax: isAtMax(for: .dragonBonusPlayer),
mainColor: Color.BettingZone.playerDark,
rowHeight: mainRowHeight,
onMain: { gameState.placeBet(type: .player, amount: selectedChip.rawValue) },
onBonus: { gameState.placeBet(type: .dragonBonusPlayer, amount: selectedChip.rawValue) }
)
.debugBorder(showDebugBorders, color: .blue, label: "PlayerRow")
}
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large))
.overlay(
@ -135,6 +164,7 @@ struct MiniBaccaratTableView: View {
)
.shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge)
}
.debugBorder(showDebugBorders, color: .orange, label: "BettingTable")
}
}
@ -150,6 +180,7 @@ private struct TopBettingRow: View {
let isBankerPairAtMax: Bool
let isTieAtMax: Bool
let isPlayerPairAtMax: Bool
let rowHeight: CGFloat
let onBankerPair: () -> Void
let onTie: () -> Void
let onPlayerPair: () -> Void
@ -194,7 +225,7 @@ private struct TopBettingRow: View {
action: onPlayerPair
)
}
.frame(height: Design.Size.topBetRowHeight)
.frame(height: rowHeight)
}
}
@ -304,6 +335,7 @@ private struct MainBetRow: View {
let isMainAtMax: Bool
let isBonusAtMax: Bool
let mainColor: Color
let rowHeight: CGFloat
let onMain: () -> Void
let onBonus: () -> Void
@ -335,7 +367,7 @@ private struct MainBetRow: View {
)
.frame(width: Design.Size.bonusZoneWidth)
}
.frame(height: Design.Size.mainBetRowHeight)
.frame(height: rowHeight)
}
}
@ -528,13 +560,31 @@ private struct ChipBadge: View {
}
}
#Preview {
// MARK: - Previews
#Preview("Betting Table") {
ZStack {
Color.Table.baseDark
.ignoresSafeArea()
TableBackgroundView()
MiniBaccaratTableView(
gameState: GameState(),
BettingTableView(
gameState: GameState(settings: GameSettings()),
selectedChip: .hundred
)
.padding()
}
}
#Preview("With Bets") {
ZStack {
TableBackgroundView()
BettingTableView(
gameState: {
let state = GameState(settings: GameSettings())
state.placeBet(type: .player, amount: 100)
state.placeBet(type: .tie, amount: 25)
return state
}(),
selectedChip: .hundred
)
.padding()

View 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
)
}
}

View 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
)
}
}

View 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)
}
}

View 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))
}
}