diff --git a/CasinoKit/Sources/CasinoKit/Exports.swift b/CasinoKit/Sources/CasinoKit/Exports.swift index 65f3f12..cf58983 100644 --- a/CasinoKit/Sources/CasinoKit/Exports.swift +++ b/CasinoKit/Sources/CasinoKit/Exports.swift @@ -45,6 +45,8 @@ // - BettingZone // MARK: - Cards (additional) +// - CardRenderer (PDF-based vector rendering for card faces) +// - CardHand, CardFan, CardsGrid (layout views) // - HandDisplayView // MARK: - Settings diff --git a/CasinoKit/Sources/CasinoKit/Models/Card.swift b/CasinoKit/Sources/CasinoKit/Models/Card.swift index 1898af2..bc9e75d 100644 --- a/CasinoKit/Sources/CasinoKit/Models/Card.swift +++ b/CasinoKit/Sources/CasinoKit/Models/Card.swift @@ -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 + } } diff --git a/CasinoKit/Sources/CasinoKit/Resources/Cards.pdf b/CasinoKit/Sources/CasinoKit/Resources/Cards.pdf new file mode 100644 index 0000000..b7f3a7f Binary files /dev/null and b/CasinoKit/Sources/CasinoKit/Resources/Cards.pdf differ diff --git a/CasinoKit/Sources/CasinoKit/Views/Cards/CardLayouts.swift b/CasinoKit/Sources/CasinoKit/Views/Cards/CardLayouts.swift new file mode 100644 index 0000000..f578bb1 --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/Cards/CardLayouts.swift @@ -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) + } + }) +} + diff --git a/CasinoKit/Sources/CasinoKit/Views/Cards/CardRenderer.swift b/CasinoKit/Sources/CasinoKit/Views/Cards/CardRenderer.swift new file mode 100644 index 0000000..f9cdcba --- /dev/null +++ b/CasinoKit/Sources/CasinoKit/Views/Cards/CardRenderer.swift @@ -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() + } +} + diff --git a/CasinoKit/Sources/CasinoKit/Views/Cards/CardView.swift b/CasinoKit/Sources/CasinoKit/Views/Cards/CardView.swift index 3aecce7..2f0526d 100644 --- a/CasinoKit/Sources/CasinoKit/Views/Cards/CardView.swift +++ b/CasinoKit/Sources/CasinoKit/Views/Cards/CardView.swift @@ -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) + } + } +}