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

This commit is contained in:
Matt Bruce 2025-12-22 12:52:55 -06:00
parent 03ee03b876
commit fa5d9f4c75
6 changed files with 447 additions and 67 deletions

View File

@ -45,6 +45,8 @@
// - BettingZone
// MARK: - Cards (additional)
// - CardRenderer (PDF-based vector rendering for card faces)
// - CardHand, CardFan, CardsGrid (layout views)
// - HandDisplayView
// MARK: - Settings

View File

@ -142,5 +142,25 @@ public struct Card: Identifiable, Equatable, Sendable {
public var accessibilityDescription: String {
"\(rank.accessibilityName) of \(suit.accessibilityName)"
}
/// The PDF page index (0-51) for this card.
/// PDF order: Spades (0-12), Hearts (13-25), Clubs (26-38), Diamonds (39-51)
/// Each suit: 2,3,4,5,6,7,8,9,10,J,Q,K,A
public var pdfIndex: Int {
let suitOffset: Int
switch suit {
case .spades: suitOffset = 0
case .hearts: suitOffset = 13
case .clubs: suitOffset = 26
case .diamonds: suitOffset = 39
}
// PDF order: 2=0, 3=1, ..., 10=8, J=9, Q=10, K=11, A=12
let rankOffset: Int
switch rank {
case .ace: rankOffset = 12 // Ace is last in PDF
default: rankOffset = rank.rawValue - 2 // 2=0, 3=1, etc.
}
return suitOffset + rankOffset
}
}

Binary file not shown.

View File

@ -0,0 +1,202 @@
//
// CardLayouts.swift
// CasinoKit
//
// Layout views for displaying multiple cards (hand, fan, grid).
//
import SwiftUI
// MARK: - Card Hand
/// A horizontal scrolling row of cards, like a hand in a card game.
public struct CardHand: View {
/// The cards in the hand.
public let cards: [Card]
/// Whether each card is face up.
public var faceUp: Bool
/// The width of each card.
public var cardWidth: CGFloat
/// The overlap amount between cards (negative for overlap, positive for spacing).
public var overlap: CGFloat
/// Creates a card hand view.
/// - Parameters:
/// - cards: The cards to display.
/// - faceUp: Whether cards are face up (default: true).
/// - cardWidth: Width of each card (default: 80).
/// - overlap: Overlap between cards, negative for overlap (default: -30).
public init(
cards: [Card],
faceUp: Bool = true,
cardWidth: CGFloat = CasinoDesign.Size.cardWidth,
overlap: CGFloat = -CasinoDesign.Spacing.xLarge
) {
self.cards = cards
self.faceUp = faceUp
self.cardWidth = cardWidth
self.overlap = overlap
}
public var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: overlap) {
ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in
CardView(card: card, isFaceUp: faceUp, cardWidth: cardWidth)
.zIndex(Double(index))
}
}
.padding(.horizontal, CasinoDesign.Spacing.medium)
.padding(.vertical, CasinoDesign.Spacing.small)
}
}
}
// MARK: - Card Fan
/// A fan-shaped arrangement of cards.
public struct CardFan: View {
/// The cards to display.
public let cards: [Card]
/// Whether each card is face up.
public var faceUp: Bool
/// The width of each card.
public var cardWidth: CGFloat
/// The total angle span of the fan in degrees.
public var angleSpan: Double
/// Creates a fan of cards.
/// - Parameters:
/// - cards: The cards to display.
/// - faceUp: Whether cards are face up (default: true).
/// - cardWidth: Width of each card (default: 100).
/// - angleSpan: Total angle span in degrees (default: 60).
public init(
cards: [Card],
faceUp: Bool = true,
cardWidth: CGFloat = CasinoDesign.Size.cardWidthLarge,
angleSpan: Double = 60
) {
self.cards = cards
self.faceUp = faceUp
self.cardWidth = cardWidth
self.angleSpan = angleSpan
}
public var body: some View {
ZStack {
ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in
let angle = angleForCard(at: index)
CardView(card: card, isFaceUp: faceUp, cardWidth: cardWidth)
.rotationEffect(.degrees(angle), anchor: .bottom)
.offset(y: -cardWidth * 0.3)
.zIndex(Double(index))
}
}
.frame(height: cardWidth * CasinoDesign.Size.cardAspectRatio + CasinoDesign.Spacing.xLarge)
}
private func angleForCard(at index: Int) -> Double {
guard cards.count > 1 else { return 0 }
let step = angleSpan / Double(cards.count - 1)
return -angleSpan / 2 + step * Double(index)
}
}
// MARK: - Cards Grid
/// A grid view displaying cards.
public struct CardsGrid: View {
/// The cards to display.
public let cards: [Card]
/// Whether each card is face up.
public var faceUp: Bool
/// The minimum width for each card.
public var minCardWidth: CGFloat
/// Spacing between cards.
public var spacing: CGFloat
/// Creates a grid showing specific cards.
/// - Parameters:
/// - cards: The cards to display.
/// - faceUp: Whether cards are face up (default: true).
/// - minCardWidth: Minimum width for each card (default: 80).
/// - spacing: Spacing between cards (default: 10).
public init(
cards: [Card],
faceUp: Bool = true,
minCardWidth: CGFloat = CasinoDesign.Size.cardWidth,
spacing: CGFloat = CasinoDesign.Spacing.small
) {
self.cards = cards
self.faceUp = faceUp
self.minCardWidth = minCardWidth
self.spacing = spacing
}
public var body: some View {
ScrollView {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: minCardWidth), spacing: spacing)],
spacing: spacing
) {
ForEach(cards) { card in
CardView(card: card, isFaceUp: faceUp, cardWidth: minCardWidth)
}
}
.padding(spacing)
}
}
}
// MARK: - Previews
#Preview("Card Hand") {
ZStack {
Color(red: 0.05, green: 0.35, blue: 0.15)
.ignoresSafeArea()
CardHand(cards: [
Card(suit: .hearts, rank: .ace),
Card(suit: .hearts, rank: .king),
Card(suit: .hearts, rank: .queen),
Card(suit: .hearts, rank: .jack),
Card(suit: .hearts, rank: .ten)
])
}
}
#Preview("Card Fan") {
ZStack {
Color(red: 0.05, green: 0.35, blue: 0.15)
.ignoresSafeArea()
CardFan(cards: [
Card(suit: .spades, rank: .ace),
Card(suit: .hearts, rank: .king),
Card(suit: .diamonds, rank: .queen),
Card(suit: .clubs, rank: .jack),
Card(suit: .spades, rank: .ten)
])
.padding()
}
}
#Preview("Cards Grid") {
CardsGrid(cards: Suit.allCases.flatMap { suit in
[Rank.ace, .king, .queen, .jack].map { rank in
Card(suit: suit, rank: rank)
}
})
}

View File

@ -0,0 +1,143 @@
//
// CardRenderer.swift
// CasinoKit
//
// Renders card images from the bundled PDF at any resolution.
// Based on CardKit by Bret Taylor (2013), Swift conversion 2024.
//
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
import CoreGraphics
import PDFKit
/// Renders card images from the bundled PDF at any resolution.
/// Cards are vector-based and can be rendered at any size without quality loss.
public final class CardRenderer: @unchecked Sendable {
/// Shared singleton instance.
public static let shared = CardRenderer()
private var pdfDocument: CGPDFDocument?
private var imageCache: [CacheKey: CGImage] = [:]
private let lock = NSLock()
private struct CacheKey: Hashable {
let index: Int
let width: Int
let height: Int
let scale: CGFloat
}
private init() {
loadPDF()
}
private func loadPDF() {
guard let url = Bundle.module.url(forResource: "Cards", withExtension: "pdf") else {
print("CasinoKit: Could not find Cards.pdf in bundle")
return
}
pdfDocument = CGPDFDocument(url as CFURL)
}
/// The aspect ratio (height / width) of a card in the PDF.
public static let aspectRatio: CGFloat = 1.3966
/// Calculates the height for a given width, maintaining the card aspect ratio.
public static func height(forWidth width: CGFloat) -> CGFloat {
(width * aspectRatio).rounded(.up)
}
/// Renders a card image at the specified size.
/// - Parameters:
/// - card: The card to render.
/// - size: The size to render at.
/// - scale: The display scale (defaults to 2.0 for retina).
/// - Returns: A CGImage of the card, or nil if rendering fails.
public func renderCard(_ card: Card, size: CGSize, scale: CGFloat = 2.0) -> CGImage? {
renderPage(at: card.pdfIndex, size: size, scale: scale)
}
/// Renders the card back image at the specified size.
/// Note: The app uses a custom themed card back, but this is available
/// if you want to use the PDF card back instead.
/// - Parameters:
/// - size: The size to render at.
/// - scale: The display scale.
/// - Returns: A CGImage of the card back, or nil if rendering fails.
public func renderCardBack(size: CGSize, scale: CGFloat = 2.0) -> CGImage? {
// Card back is at index 52 (page 53 in PDF)
renderPage(at: 52, size: size, scale: scale)
}
private func renderPage(at index: Int, size: CGSize, scale: CGFloat) -> CGImage? {
guard let document = pdfDocument else { return nil }
let cacheKey = CacheKey(
index: index,
width: Int(size.width),
height: Int(size.height),
scale: scale
)
lock.lock()
if let cached = imageCache[cacheKey] {
lock.unlock()
return cached
}
lock.unlock()
// PDF pages are 1-indexed
guard let page = document.page(at: index + 1) else { return nil }
let scaledSize = CGSize(
width: size.width * scale,
height: size.height * scale
)
let colorSpace = CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: Int(scaledSize.width),
height: Int(scaledSize.height),
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
// Clear to transparent
context.clear(CGRect(origin: .zero, size: scaledSize))
// Get the PDF page bounds
let pdfBox = page.getBoxRect(.bleedBox)
// Calculate scale to fit
let xScale = scaledSize.width / pdfBox.width
let yScale = scaledSize.height / pdfBox.height
let fitScale = min(xScale, yScale)
// Apply transformations
context.scaleBy(x: fitScale, y: fitScale)
context.drawPDFPage(page)
guard let image = context.makeImage() else { return nil }
lock.lock()
imageCache[cacheKey] = image
lock.unlock()
return image
}
/// Clears the image cache to free memory.
public func clearCache() {
lock.lock()
imageCache.removeAll()
lock.unlock()
}
}

View File

@ -2,12 +2,15 @@
// CardView.swift
// CasinoKit
//
// Beautiful playing card view with flip animation.
// Beautiful playing card view with vector PDF rendering for faces
// and custom themed back design with flip animation.
//
import SwiftUI
/// A single playing card with elegant design and flip animation.
/// Uses vector PDF rendering for card faces (crisp at any size).
/// Uses custom themed design for card backs.
public struct CardView: View {
let card: Card
let isFaceUp: Bool
@ -60,23 +63,14 @@ public struct CardView: View {
}
}
/// The front face of a playing card showing rank and suit.
/// The front face of a playing card using vector PDF rendering.
public struct CardFrontView: View {
let card: Card
let width: CGFloat
let height: CGFloat
let theme: any CasinoTheme
// MARK: - Layout Constants
private let rankFontRatio: CGFloat = 0.19
private let suitFontRatio: CGFloat = 0.15
private let centerSuitFontRatio: CGFloat = 0.4
private let contentPaddingRatio: CGFloat = 0.04
private var suitColor: Color {
card.suit.isRed ? .red : .black
}
@State private var image: CGImage?
public init(card: Card, width: CGFloat, height: CGFloat, theme: any CasinoTheme = DefaultCasinoTheme()) {
self.card = card
@ -87,72 +81,47 @@ public struct CardFrontView: View {
public var body: some View {
ZStack {
// Card background with subtle gradient
// White card background
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.small)
.fill(
LinearGradient(
colors: [.white, theme.cardFrontColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.fill(Color.white)
// Card border
// Card face from PDF
if let image = image {
Image(decorative: image, scale: 2.0)
.resizable()
.aspectRatio(contentMode: .fit)
}
// Subtle border
RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.small)
.strokeBorder(
LinearGradient(
colors: [Color(white: 0.8), Color(white: 0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
Color.gray.opacity(CasinoDesign.Opacity.light),
lineWidth: CasinoDesign.LineWidth.thin
)
// Card content
VStack {
// Top left corner
HStack {
VStack(spacing: 0) {
Text(card.rank.symbol)
.font(.system(size: width * rankFontRatio, weight: .bold, design: .serif))
Text(card.suit.rawValue)
.font(.system(size: width * suitFontRatio))
}
.foregroundStyle(suitColor)
Spacer()
}
// Center suit (large)
Text(card.suit.rawValue)
.font(.system(size: width * centerSuitFontRatio))
.foregroundStyle(suitColor)
// Bottom right corner (inverted)
HStack {
Spacer()
VStack(spacing: 0) {
Text(card.suit.rawValue)
.font(.system(size: width * suitFontRatio))
Text(card.rank.symbol)
.font(.system(size: width * rankFontRatio, weight: .bold, design: .serif))
}
.foregroundStyle(suitColor)
.rotationEffect(.degrees(180))
}
}
.padding(width * contentPaddingRatio)
}
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.small))
.shadow(
color: .black.opacity(CasinoDesign.Opacity.light),
radius: CasinoDesign.Shadow.radiusSmall,
x: CasinoDesign.Shadow.offsetMedium,
y: CasinoDesign.Shadow.offsetMedium
)
.onAppear {
renderImage()
}
.onChange(of: card.pdfIndex) { _, _ in
renderImage()
}
}
private func renderImage() {
guard width > 0 && height > 0 else { return }
image = CardRenderer.shared.renderCard(card, size: CGSize(width: width, height: height))
}
}
/// The back of a playing card with elegant pattern.
/// The back of a playing card with elegant themed pattern.
public struct CardBackView: View {
let width: CGFloat
let height: CGFloat
@ -164,7 +133,6 @@ public struct CardBackView: View {
private let patternPaddingRatio: CGFloat = 0.12
private let emblemGradientRatio: CGFloat = 0.15
private let emblemSizeRatio: CGFloat = 0.3
private let logoFontRatio: CGFloat = 0.18
public init(width: CGFloat, height: CGFloat, theme: any CasinoTheme = DefaultCasinoTheme()) {
self.width = width
@ -299,16 +267,61 @@ public struct CardPlaceholderView: View {
}
}
#Preview {
// MARK: - Previews
#Preview("Card Views") {
ZStack {
Color(red: 0.05, green: 0.35, blue: 0.15)
.ignoresSafeArea()
HStack(spacing: CasinoDesign.Spacing.xLarge) {
CardView(card: Card(suit: .hearts, rank: .ace), isFaceUp: true, cardWidth: 95)
CardView(card: Card(suit: .spades, rank: .king), isFaceUp: true, cardWidth: 95)
CardView(card: Card(suit: .diamonds, rank: .seven), isFaceUp: false, cardWidth: 95)
VStack(spacing: CasinoDesign.Spacing.large) {
// Row 1: Face cards
HStack(spacing: CasinoDesign.Spacing.medium) {
CardView(card: Card(suit: .hearts, rank: .ace), isFaceUp: true, cardWidth: 70)
CardView(card: Card(suit: .spades, rank: .king), isFaceUp: true, cardWidth: 70)
CardView(card: Card(suit: .diamonds, rank: .queen), isFaceUp: true, cardWidth: 70)
CardView(card: Card(suit: .clubs, rank: .jack), isFaceUp: true, cardWidth: 70)
}
// Row 2: Number cards
HStack(spacing: CasinoDesign.Spacing.medium) {
CardView(card: Card(suit: .hearts, rank: .ten), isFaceUp: true, cardWidth: 70)
CardView(card: Card(suit: .spades, rank: .seven), isFaceUp: true, cardWidth: 70)
CardView(card: Card(suit: .diamonds, rank: .four), isFaceUp: true, cardWidth: 70)
CardView(card: Card(suit: .clubs, rank: .two), isFaceUp: true, cardWidth: 70)
}
// Row 3: Card back (custom themed design)
HStack(spacing: CasinoDesign.Spacing.medium) {
CardView(card: Card(suit: .hearts, rank: .ace), isFaceUp: false, cardWidth: 70)
CardView(card: Card(suit: .spades, rank: .ace), isFaceUp: false, cardWidth: 70)
}
}
}
}
#Preview("All Suits - Aces") {
ZStack {
Color(red: 0.05, green: 0.35, blue: 0.15)
.ignoresSafeArea()
HStack(spacing: CasinoDesign.Spacing.medium) {
CardView(card: Card(suit: .spades, rank: .ace), cardWidth: 80)
CardView(card: Card(suit: .hearts, rank: .ace), cardWidth: 80)
CardView(card: Card(suit: .clubs, rank: .ace), cardWidth: 80)
CardView(card: Card(suit: .diamonds, rank: .ace), cardWidth: 80)
}
}
}
#Preview("Large Cards") {
ZStack {
Color(red: 0.05, green: 0.35, blue: 0.15)
.ignoresSafeArea()
HStack(spacing: CasinoDesign.Spacing.large) {
CardView(card: Card(suit: .hearts, rank: .king), cardWidth: 120)
CardView(card: Card(suit: .spades, rank: .ace), isFaceUp: false, cardWidth: 120)
}
}
}