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

This commit is contained in:
Matt Bruce 2025-12-16 20:17:21 -06:00
parent 2b7b770d3a
commit c846ef05ac
8 changed files with 463 additions and 371 deletions

View File

@ -0,0 +1,117 @@
//
// ChipDenomination.swift
// Baccarat
//
// The available chip denominations with their properties.
//
import SwiftUI
/// The available chip denominations.
enum ChipDenomination: Int, CaseIterable, Identifiable {
case ten = 10
case twentyFive = 25
case fifty = 50
case hundred = 100
case fiveHundred = 500
case thousand = 1_000
case fiveThousand = 5_000
case tenThousand = 10_000
case twentyFiveThousand = 25_000
case fiftyThousand = 50_000
case hundredThousand = 100_000
var id: Int { rawValue }
/// The display text for this denomination.
var displayText: String {
switch self {
case .ten: return "10"
case .twentyFive: return "25"
case .fifty: return "50"
case .hundred: return "100"
case .fiveHundred: return "500"
case .thousand: return "1K"
case .fiveThousand: return "5K"
case .tenThousand: return "10K"
case .twentyFiveThousand: return "25K"
case .fiftyThousand: return "50K"
case .hundredThousand: return "100K"
}
}
/// The minimum balance required to show this chip in the selector.
/// Higher chips unlock as you win more!
var unlockBalance: Int {
switch self {
case .ten, .twentyFive, .fifty, .hundred: return 0 // Always available
case .fiveHundred: return 500
case .thousand: return 1_000
case .fiveThousand: return 5_000
case .tenThousand: return 10_000
case .twentyFiveThousand: return 25_000
case .fiftyThousand: return 50_000
case .hundredThousand: return 100_000
}
}
/// Whether this chip should be shown based on the player's balance.
func isUnlocked(forBalance balance: Int) -> Bool {
balance >= unlockBalance
}
/// Returns chips that should be visible for a given balance.
static func availableChips(forBalance balance: Int) -> [ChipDenomination] {
allCases.filter { $0.isUnlocked(forBalance: balance) }
}
/// The primary color for this chip.
var primaryColor: Color {
switch self {
case .ten: return Color.Chip.tenBase
case .twentyFive: return Color.Chip.twentyFiveBase
case .fifty: return Color.Chip.fiftyBase
case .hundred: return Color.Chip.hundredBase
case .fiveHundred: return Color.Chip.fiveHundredBase
case .thousand: return Color.Chip.thousandBase
case .fiveThousand: return Color.Chip.fiveThousandBase
case .tenThousand: return Color.Chip.tenThousandBase
case .twentyFiveThousand: return Color.Chip.twentyFiveThousandBase
case .fiftyThousand: return Color.Chip.fiftyThousandBase
case .hundredThousand: return Color.Chip.hundredThousandBase
}
}
/// The secondary/accent color for this chip.
var secondaryColor: Color {
switch self {
case .ten: return Color.Chip.tenHighlight
case .twentyFive: return Color.Chip.twentyFiveHighlight
case .fifty: return Color.Chip.fiftyHighlight
case .hundred: return Color.Chip.hundredHighlight
case .fiveHundred: return Color.Chip.fiveHundredHighlight
case .thousand: return Color.Chip.thousandHighlight
case .fiveThousand: return Color.Chip.fiveThousandHighlight
case .tenThousand: return Color.Chip.tenThousandHighlight
case .twentyFiveThousand: return Color.Chip.twentyFiveThousandHighlight
case .fiftyThousand: return Color.Chip.fiftyThousandHighlight
case .hundredThousand: return Color.Chip.hundredThousandHighlight
}
}
/// The edge stripe color for this chip.
var stripeColor: Color {
switch self {
case .ten, .twentyFive, .fifty: return .white
case .hundred: return Color.Chip.goldStripe
case .fiveHundred: return .white
case .thousand: return .black
case .fiveThousand: return Color.Chip.goldStripe
case .tenThousand: return .white
case .twentyFiveThousand: return Color.Chip.goldStripe
case .fiftyThousand: return Color.Chip.darkStripe
case .hundredThousand: return Color.Chip.goldRubyStripe
}
}
}

View File

@ -1,291 +0,0 @@
//
// ChipView.swift
// Baccarat
//
// Casino-style betting chips with realistic design.
//
import SwiftUI
/// The available chip denominations.
enum ChipDenomination: Int, CaseIterable, Identifiable {
case ten = 10
case twentyFive = 25
case fifty = 50
case hundred = 100
case fiveHundred = 500
case thousand = 1_000
case fiveThousand = 5_000
case tenThousand = 10_000
case twentyFiveThousand = 25_000
case fiftyThousand = 50_000
case hundredThousand = 100_000
var id: Int { rawValue }
/// The display text for this denomination.
var displayText: String {
switch self {
case .ten: return "10"
case .twentyFive: return "25"
case .fifty: return "50"
case .hundred: return "100"
case .fiveHundred: return "500"
case .thousand: return "1K"
case .fiveThousand: return "5K"
case .tenThousand: return "10K"
case .twentyFiveThousand: return "25K"
case .fiftyThousand: return "50K"
case .hundredThousand: return "100K"
}
}
/// The minimum balance required to show this chip in the selector.
/// Higher chips unlock as you win more!
var unlockBalance: Int {
switch self {
case .ten, .twentyFive, .fifty, .hundred: return 0 // Always available
case .fiveHundred: return 500
case .thousand: return 1_000
case .fiveThousand: return 5_000
case .tenThousand: return 10_000
case .twentyFiveThousand: return 25_000
case .fiftyThousand: return 50_000
case .hundredThousand: return 100_000
}
}
/// Whether this chip should be shown based on the player's balance.
func isUnlocked(forBalance balance: Int) -> Bool {
balance >= unlockBalance
}
/// Returns chips that should be visible for a given balance.
static func availableChips(forBalance balance: Int) -> [ChipDenomination] {
allCases.filter { $0.isUnlocked(forBalance: balance) }
}
/// The primary color for this chip.
var primaryColor: Color {
switch self {
case .ten: return Color.Chip.tenBase
case .twentyFive: return Color.Chip.twentyFiveBase
case .fifty: return Color.Chip.fiftyBase
case .hundred: return Color.Chip.hundredBase
case .fiveHundred: return Color.Chip.fiveHundredBase
case .thousand: return Color.Chip.thousandBase
case .fiveThousand: return Color.Chip.fiveThousandBase
case .tenThousand: return Color.Chip.tenThousandBase
case .twentyFiveThousand: return Color.Chip.twentyFiveThousandBase
case .fiftyThousand: return Color.Chip.fiftyThousandBase
case .hundredThousand: return Color.Chip.hundredThousandBase
}
}
/// The secondary/accent color for this chip.
var secondaryColor: Color {
switch self {
case .ten: return Color.Chip.tenHighlight
case .twentyFive: return Color.Chip.twentyFiveHighlight
case .fifty: return Color.Chip.fiftyHighlight
case .hundred: return Color.Chip.hundredHighlight
case .fiveHundred: return Color.Chip.fiveHundredHighlight
case .thousand: return Color.Chip.thousandHighlight
case .fiveThousand: return Color.Chip.fiveThousandHighlight
case .tenThousand: return Color.Chip.tenThousandHighlight
case .twentyFiveThousand: return Color.Chip.twentyFiveThousandHighlight
case .fiftyThousand: return Color.Chip.fiftyThousandHighlight
case .hundredThousand: return Color.Chip.hundredThousandHighlight
}
}
/// The edge stripe color for this chip.
var stripeColor: Color {
switch self {
case .ten, .twentyFive, .fifty: return .white
case .hundred: return Color.Chip.goldStripe
case .fiveHundred: return .white
case .thousand: return .black
case .fiveThousand: return Color.Chip.goldStripe
case .tenThousand: return .white
case .twentyFiveThousand: return Color.Chip.goldStripe
case .fiftyThousand: return Color.Chip.darkStripe
case .hundredThousand: return Color.Chip.goldRubyStripe
}
}
}
/// A realistic casino-style betting chip.
struct ChipView: View {
let denomination: ChipDenomination
let size: CGFloat
var isSelected: Bool = false
init(denomination: ChipDenomination, size: CGFloat = 60, isSelected: Bool = false) {
self.denomination = denomination
self.size = size
self.isSelected = isSelected
}
var body: some View {
ZStack {
// Base circle with gradient
Circle()
.fill(
RadialGradient(
colors: [
denomination.secondaryColor,
denomination.primaryColor,
denomination.primaryColor.opacity(0.8)
],
center: .topLeading,
startRadius: 0,
endRadius: size
)
)
// Edge stripes pattern
ChipEdgePattern(stripeColor: denomination.stripeColor)
.clipShape(.circle)
// Inner circle
Circle()
.fill(
RadialGradient(
colors: [
denomination.secondaryColor,
denomination.primaryColor
],
center: .topLeading,
startRadius: 0,
endRadius: size * 0.4
)
)
.frame(width: size * 0.65, height: size * 0.65)
// Inner border
Circle()
.strokeBorder(
denomination.stripeColor.opacity(0.8),
lineWidth: 2
)
.frame(width: size * 0.65, height: size * 0.65)
// Denomination text
Text(denomination.displayText)
.font(.system(size: size * 0.25, weight: .heavy, design: .rounded))
.foregroundStyle(denomination.stripeColor)
.shadow(color: .black.opacity(0.3), radius: 1, x: 1, y: 1)
// Outer border
Circle()
.strokeBorder(
LinearGradient(
colors: [
Color.white.opacity(0.4),
Color.black.opacity(0.3)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 2
)
// Selection glow
if isSelected {
Circle()
.strokeBorder(Color.yellow, lineWidth: 3)
.frame(width: size + 6, height: size + 6)
}
}
.frame(width: size, height: size)
.shadow(color: .black.opacity(0.4), radius: isSelected ? 8 : 4, x: 2, y: 3)
.scaleEffect(isSelected ? 1.1 : 1.0)
.animation(.spring(duration: 0.2), value: isSelected)
}
}
/// The edge stripe pattern for chips.
struct ChipEdgePattern: View {
let stripeColor: Color
var body: some View {
Canvas { context, size in
let center = CGPoint(x: size.width / 2, y: size.height / 2)
let radius = min(size.width, size.height) / 2
let stripeCount = 16
let stripeWidth: CGFloat = 6
for i in 0..<stripeCount {
let angle = Double(i) * (360.0 / Double(stripeCount)) * .pi / 180
let innerRadius = radius * 0.75
let outerRadius = radius * 0.98
let startX = center.x + cos(angle) * innerRadius
let startY = center.y + sin(angle) * innerRadius
let endX = center.x + cos(angle) * outerRadius
let endY = center.y + sin(angle) * outerRadius
var path = Path()
path.move(to: CGPoint(x: startX, y: startY))
path.addLine(to: CGPoint(x: endX, y: endY))
context.stroke(
path,
with: .color(stripeColor),
lineWidth: stripeWidth
)
}
}
}
}
/// A stack of chips showing a bet amount.
struct ChipStackView: View {
let amount: Int
let maxChips: Int = 5
private var chipBreakdown: [(ChipDenomination, Int)] {
var remaining = amount
var result: [(ChipDenomination, Int)] = []
for denom in ChipDenomination.allCases.reversed() {
let count = remaining / denom.rawValue
if count > 0 {
result.append((denom, min(count, maxChips)))
remaining -= count * denom.rawValue
}
}
return result
}
var body: some View {
ZStack {
ForEach(chipBreakdown.indices, id: \.self) { index in
let (denom, _) = chipBreakdown[index]
ChipView(denomination: denom, size: 40)
.offset(y: CGFloat(-index * 4))
}
}
}
}
#Preview {
ZStack {
Color.Table.preview
.ignoresSafeArea()
VStack(spacing: Design.Spacing.xxxLarge) {
HStack(spacing: Design.Spacing.xLarge) {
ForEach(ChipDenomination.allCases) { denom in
ChipView(denomination: denom, size: Design.Size.chipSelector)
}
}
ChipView(denomination: .hundred, size: 80, isSelected: true)
}
}
}

View File

@ -0,0 +1,62 @@
//
// ChipEdgePattern.swift
// Baccarat
//
// The edge stripe pattern for casino chips.
//
import SwiftUI
/// The edge stripe pattern for chips.
struct ChipEdgePattern: View {
let stripeColor: Color
// MARK: - Layout Constants
private let stripeCount = 16
private let stripeWidth: CGFloat = 6
private let innerRadiusRatio: CGFloat = 0.75
private let outerRadiusRatio: CGFloat = 0.98
var body: some View {
Canvas { context, size in
let center = CGPoint(x: size.width / 2, y: size.height / 2)
let radius = min(size.width, size.height) / 2
for i in 0..<stripeCount {
let angle = Double(i) * (360.0 / Double(stripeCount)) * .pi / 180
let innerRadius = radius * innerRadiusRatio
let outerRadius = radius * outerRadiusRatio
let startX = center.x + cos(angle) * innerRadius
let startY = center.y + sin(angle) * innerRadius
let endX = center.x + cos(angle) * outerRadius
let endY = center.y + sin(angle) * outerRadius
var path = Path()
path.move(to: CGPoint(x: startX, y: startY))
path.addLine(to: CGPoint(x: endX, y: endY))
context.stroke(
path,
with: .color(stripeColor),
lineWidth: stripeWidth
)
}
}
}
}
#Preview {
ZStack {
Color.Table.preview
.ignoresSafeArea()
ChipEdgePattern(stripeColor: .white)
.frame(width: 100, height: 100)
.clipShape(.circle)
.background(Circle().fill(.red))
}
}

View File

@ -0,0 +1,104 @@
//
// ChipOnTable.swift
// Baccarat
//
// A simplified chip display for showing bets on the table.
//
import SwiftUI
/// A simplified chip for displaying bets on the table.
struct ChipOnTable: View {
let amount: Int
var showMax: Bool = false
// MARK: - Layout Constants
// Fixed sizes: chip face has strict space constraints
private let chipSize = Design.Size.chipSmall
private let innerRingSize: CGFloat = 26
private let gradientEndRadius: CGFloat = 20
private let maxBadgeFontSize = Design.BaseFontSize.xxSmall
private let maxBadgeOffsetX: CGFloat = 6
private let maxBadgeOffsetY: CGFloat = -4
// MARK: - Computed Properties
private var chipColor: Color {
switch amount {
case 0..<50: return .blue
case 50..<100: return .orange
case 100..<500: return .black
case 500..<1000: return .purple
default: return Color.Chip.gold
}
}
private var displayText: String {
amount >= 1000 ? "\(amount / 1000)K" : "\(amount)"
}
private var textFontSize: CGFloat {
amount >= 1000 ? Design.BaseFontSize.small : 11
}
// MARK: - Body
var body: some View {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [chipColor.opacity(0.9), chipColor],
center: .topLeading,
startRadius: 0,
endRadius: gradientEndRadius
)
)
.frame(width: chipSize, height: chipSize)
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.heavy), lineWidth: Design.LineWidth.medium)
.frame(width: chipSize, height: chipSize)
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
.frame(width: innerRingSize, height: innerRingSize)
Text(displayText)
.font(.system(size: textFontSize, weight: .bold))
.foregroundStyle(.white)
}
.shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusSmall, x: 1, y: 2)
.overlay(alignment: .topTrailing) {
if showMax {
Text("MAX")
.font(.system(size: maxBadgeFontSize, weight: .black))
.foregroundStyle(.white)
.padding(.horizontal, Design.Spacing.xSmall)
.padding(.vertical, Design.Spacing.xxSmall)
.background(
Capsule()
.fill(Color.red)
)
.offset(x: maxBadgeOffsetX, y: maxBadgeOffsetY)
}
}
}
}
#Preview {
ZStack {
Color.Table.preview
.ignoresSafeArea()
HStack(spacing: Design.Spacing.xLarge) {
ChipOnTable(amount: 25)
ChipOnTable(amount: 100)
ChipOnTable(amount: 500)
ChipOnTable(amount: 1000)
ChipOnTable(amount: 5000, showMax: true)
}
}
}

View File

@ -0,0 +1,64 @@
//
// ChipStackView.swift
// Baccarat
//
// A stacked display of chips representing a bet amount.
//
import SwiftUI
/// A stack of chips showing a bet amount.
struct ChipStackView: View {
let amount: Int
let maxChips: Int
// MARK: - Layout Constants
private let chipSize: CGFloat = 40
private let stackOffset: CGFloat = 4
init(amount: Int, maxChips: Int = 5) {
self.amount = amount
self.maxChips = maxChips
}
private var chipBreakdown: [(ChipDenomination, Int)] {
var remaining = amount
var result: [(ChipDenomination, Int)] = []
for denom in ChipDenomination.allCases.reversed() {
let count = remaining / denom.rawValue
if count > 0 {
result.append((denom, min(count, maxChips)))
remaining -= count * denom.rawValue
}
}
return result
}
var body: some View {
ZStack {
ForEach(chipBreakdown.indices, id: \.self) { index in
let (denom, _) = chipBreakdown[index]
ChipView(denomination: denom, size: chipSize)
.offset(y: CGFloat(-index) * stackOffset)
}
}
}
}
#Preview {
ZStack {
Color.Table.preview
.ignoresSafeArea()
HStack(spacing: Design.Spacing.xxxLarge) {
ChipStackView(amount: 100)
ChipStackView(amount: 550)
ChipStackView(amount: 1500)
ChipStackView(amount: 10_000)
}
}
}

View File

@ -0,0 +1,116 @@
//
// ChipView.swift
// Baccarat
//
// A realistic casino-style betting chip.
//
import SwiftUI
/// A realistic casino-style betting chip.
struct ChipView: View {
let denomination: ChipDenomination
let size: CGFloat
var isSelected: Bool = false
init(denomination: ChipDenomination, size: CGFloat = 60, isSelected: Bool = false) {
self.denomination = denomination
self.size = size
self.isSelected = isSelected
}
var body: some View {
ZStack {
// Base circle with gradient
Circle()
.fill(
RadialGradient(
colors: [
denomination.secondaryColor,
denomination.primaryColor,
denomination.primaryColor.opacity(0.8)
],
center: .topLeading,
startRadius: 0,
endRadius: size
)
)
// Edge stripes pattern
ChipEdgePattern(stripeColor: denomination.stripeColor)
.clipShape(.circle)
// Inner circle
Circle()
.fill(
RadialGradient(
colors: [
denomination.secondaryColor,
denomination.primaryColor
],
center: .topLeading,
startRadius: 0,
endRadius: size * 0.4
)
)
.frame(width: size * 0.65, height: size * 0.65)
// Inner border
Circle()
.strokeBorder(
denomination.stripeColor.opacity(0.8),
lineWidth: Design.LineWidth.medium
)
.frame(width: size * 0.65, height: size * 0.65)
// Denomination text
Text(denomination.displayText)
.font(.system(size: size * 0.25, weight: .heavy, design: .rounded))
.foregroundStyle(denomination.stripeColor)
.shadow(color: .black.opacity(Design.Opacity.light), radius: 1, x: 1, y: 1)
// Outer border
Circle()
.strokeBorder(
LinearGradient(
colors: [
Color.white.opacity(Design.Opacity.overlay),
Color.black.opacity(Design.Opacity.light)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: Design.LineWidth.medium
)
// Selection glow
if isSelected {
Circle()
.strokeBorder(Color.yellow, lineWidth: Design.LineWidth.thick)
.frame(width: size + 6, height: size + 6)
}
}
.frame(width: size, height: size)
.shadow(color: .black.opacity(Design.Opacity.overlay), radius: isSelected ? 8 : 4, x: 2, y: 3)
.scaleEffect(isSelected ? 1.1 : 1.0)
.animation(.spring(duration: 0.2), value: isSelected)
}
}
#Preview {
ZStack {
Color.Table.preview
.ignoresSafeArea()
VStack(spacing: Design.Spacing.xxxLarge) {
HStack(spacing: Design.Spacing.xLarge) {
ForEach(ChipDenomination.allCases) { denom in
ChipView(denomination: denom, size: Design.Size.chipSelector)
}
}
ChipView(denomination: .hundred, size: 80, isSelected: true)
}
}
}

View File

@ -450,86 +450,6 @@ struct PlayerBettingZone: View {
}
}
/// A chip displayed on the table showing bet amount.
struct ChipOnTable: View {
let amount: Int
var showMax: Bool = false
// MARK: - Layout Constants
// Fixed sizes: chip face has strict space constraints
private let chipSize = Design.Size.chipSmall
private let innerRingSize: CGFloat = 26
private let gradientEndRadius: CGFloat = 20
private let maxBadgeFontSize = Design.BaseFontSize.xxSmall
private let maxBadgeOffsetX: CGFloat = 6
private let maxBadgeOffsetY: CGFloat = -4
// MARK: - Computed Properties
private var chipColor: Color {
switch amount {
case 0..<50: return .blue
case 50..<100: return .orange
case 100..<500: return .black
case 500..<1000: return .purple
default: return Color.Chip.gold
}
}
private var displayText: String {
amount >= 1000 ? "\(amount / 1000)K" : "\(amount)"
}
private var textFontSize: CGFloat {
amount >= 1000 ? Design.BaseFontSize.small : 11
}
// MARK: - Body
var body: some View {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [chipColor.opacity(0.9), chipColor],
center: .topLeading,
startRadius: 0,
endRadius: gradientEndRadius
)
)
.frame(width: chipSize, height: chipSize)
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.heavy), lineWidth: Design.LineWidth.medium)
.frame(width: chipSize, height: chipSize)
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
.frame(width: innerRingSize, height: innerRingSize)
Text(displayText)
.font(.system(size: textFontSize, weight: .bold))
.foregroundStyle(.white)
}
.shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusSmall, x: 1, y: 2)
.overlay(alignment: .topTrailing) {
if showMax {
Text("MAX")
.font(.system(size: maxBadgeFontSize, weight: .black))
.foregroundStyle(.white)
.padding(.horizontal, Design.Spacing.xSmall)
.padding(.vertical, Design.Spacing.xxSmall)
.background(
Capsule()
.fill(Color.red)
)
.offset(x: maxBadgeOffsetX, y: maxBadgeOffsetY)
}
}
}
}
#Preview {
ZStack {
Color.Table.baseDark