diff --git a/Baccarat/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Baccarat/Resources/Localizable.xcstrings index 70857cd..9f09dc7 100644 --- a/Baccarat/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Baccarat/Resources/Localizable.xcstrings @@ -3236,10 +3236,6 @@ } } }, - "PEEL" : { - "comment" : "Text shown on the card when it's in the interactive reveal state, instructing them to peel the card.", - "isCommentAutoGenerated" : true - }, "Player" : { "localizations" : { "en" : { diff --git a/Baccarat/Baccarat/Views/Table/InteractiveCardView.swift b/Baccarat/Baccarat/Views/Table/InteractiveCardView.swift index 33b6a7e..fd5e064 100644 --- a/Baccarat/Baccarat/Views/Table/InteractiveCardView.swift +++ b/Baccarat/Baccarat/Views/Table/InteractiveCardView.swift @@ -21,25 +21,11 @@ struct InteractiveCardView: View { var body: some View { ZStack { if revealStyle == .squeeze && !isFaceUp && isWaiting { - // Specialized squeeze view - SqueezeCardView(card: card, width: cardWidth, progress: progress) - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - let dx = abs(value.translation.width) - let dy = abs(value.translation.height) - let distance = sqrt(dx*dx + dy*dy) - let maxDistance = cardWidth * 1.0 - let newProgress = min(1.0, max(progress, Double(distance / maxDistance))) - onUpdateProgress(newProgress) - } - ) - .overlay { - if progress < 0.05 { - revealInstructionOverlay(text: String(localized: "PEEL")) - } - } + PageCurlView( + card: card, + width: cardWidth, + onReveal: onReveal + ) } else { // Standard CardView (handles its own flip animation) CardView(card: card, isFaceUp: isFaceUp, cardWidth: cardWidth) diff --git a/Baccarat/Baccarat/Views/Table/PageCurlView.swift b/Baccarat/Baccarat/Views/Table/PageCurlView.swift new file mode 100644 index 0000000..4340825 --- /dev/null +++ b/Baccarat/Baccarat/Views/Table/PageCurlView.swift @@ -0,0 +1,129 @@ +// +// PageCurlView.swift +// Baccarat +// +// A SwiftUI wrapper around UIPageViewController to provide native iBooks-style +// page curl transitions for card reveals, treated as a single double-sided sheet. +// + +import SwiftUI +import UIKit +import CasinoKit + +struct PageCurlView: View { + let card: Card + let width: CGFloat + let onReveal: () -> Void + + @State private var currentIndex = 0 + + private var height: CGFloat { + width * CasinoDesign.Size.cardAspectRatio + } + + var body: some View { + PageCurlRepresentable(currentIndex: $currentIndex, onReveal: onReveal, pages: [ + // Page 0: Card Back (Top side) + AnyView( + CardBackView(width: width, height: height) + .tag(0) + .contentShape(Rectangle()) + ), + // Page 1: Card Front (Underside/Reveal side) + AnyView( + CardFrontView(card: card, width: width, height: height) + .tag(1) + ), + // Page 2: Empty space behind the card + AnyView( + Color.clear + .frame(width: width, height: height) + .tag(2) + ) + ]) + .frame(width: width, height: height) + .id("page-curl-\(card.id)") + } +} + +private struct PageCurlRepresentable: UIViewControllerRepresentable { + @Binding var currentIndex: Int + let onReveal: () -> Void + let pages: [AnyView] + + func makeUIViewController(context: Context) -> UIPageViewController { + let pageVC = UIPageViewController( + transitionStyle: .pageCurl, + navigationOrientation: .vertical, + options: [UIPageViewController.OptionsKey.spineLocation: UIPageViewController.SpineLocation.min.rawValue] + ) + + pageVC.isDoubleSided = true + pageVC.dataSource = context.coordinator + pageVC.delegate = context.coordinator + + let vc = UIHostingController(rootView: pages[0]) + vc.view.tag = 0 + vc.view.backgroundColor = .clear + pageVC.setViewControllers([vc], direction: .forward, animated: false) + + // Disable internal tap gesture recognizers to ensure purely gestural interaction + for gesture in pageVC.gestureRecognizers { + if gesture is UITapGestureRecognizer { + gesture.isEnabled = false + } + } + + return pageVC + } + + func updateUIViewController(_ pageVC: UIPageViewController, context: Context) { } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { + let parent: PageCurlRepresentable + + init(_ parent: PageCurlRepresentable) { + self.parent = parent + } + + func pageViewController(_ pageVC: UIPageViewController, + viewControllerBefore vc: UIViewController) -> UIViewController? { + let index = vc.view.tag + guard index > 0 else { return nil } + + let prevVC = UIHostingController(rootView: parent.pages[index - 1]) + prevVC.view.tag = index - 1 + prevVC.view.backgroundColor = .clear + return prevVC + } + + func pageViewController(_ pageVC: UIPageViewController, + viewControllerAfter vc: UIViewController) -> UIViewController? { + let index = vc.view.tag + guard index < parent.pages.count - 1 else { return nil } + + let nextVC = UIHostingController(rootView: parent.pages[index + 1]) + nextVC.view.tag = index + 1 + nextVC.view.backgroundColor = .clear + return nextVC + } + + func pageViewController(_ pageVC: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool) { + guard completed, + let visibleVC = pageVC.viewControllers?.first else { return } + + let newIndex = visibleVC.view.tag + parent.currentIndex = newIndex + if newIndex >= 1 { + parent.onReveal() + } + } + } +} diff --git a/Baccarat/Baccarat/Views/Table/SqueezeCardView.swift b/Baccarat/Baccarat/Views/Table/SqueezeCardView.swift index 5346f01..8fb113d 100644 --- a/Baccarat/Baccarat/Views/Table/SqueezeCardView.swift +++ b/Baccarat/Baccarat/Views/Table/SqueezeCardView.swift @@ -2,16 +2,23 @@ // SqueezeCardView.swift // Baccarat // -// A premium card view that allows for a gestural "squeeze" or "peel" reveal. +// A premium card view that allows for a gestural "squeeze" or "peel" reveal, +// mimicking a real-world page flip from the corners. // import SwiftUI import CasinoKit +enum SqueezeCorner { + case bottomLeft + case bottomRight +} + struct SqueezeCardView: View { let card: Card let width: CGFloat - let progress: Double // 0.0 (Back) to 1.0 (Front) + let progress: Double // 0.0 to 1.0 + let corner: SqueezeCorner private var height: CGFloat { width * CasinoDesign.Size.cardAspectRatio @@ -19,29 +26,34 @@ struct SqueezeCardView: View { var body: some View { ZStack { - // The front is always underneath + // 1. The front is always at the bottom CardFrontView(card: card, width: width, height: height) - // The back is on top and masked away as progress increases + // 2. The back is on top, masked by the "unpeeled" area CardBackView(width: width, height: height) .mask( - SqueezeMask(progress: progress) - .frame(width: width, height: height) - ) - .shadow( - color: .black.opacity(Design.Opacity.medium * (1.0 - progress)), - radius: 2, - x: 2, - y: 2 + SqueezeMask(progress: progress, corner: corner) + .fill(Style.maskFill) ) + + // 3. The "Fold" - visual affordance of the peeled corner + if progress > 0 && progress < 1.0 { + FoldEffect(progress: progress, corner: corner, width: width, height: height) + } } .frame(width: width, height: height) + .clipShape(RoundedRectangle(cornerRadius: width * 0.05)) + } + + private enum Style { + static let maskFill = Color.black } } -/// A mask that peels away relative to progress. +/// A mask that represents the part of the card back that is NOT yet peeled. struct SqueezeMask: Shape { var progress: Double + var corner: SqueezeCorner var animatableData: Double { get { progress } @@ -50,27 +62,130 @@ struct SqueezeMask: Shape { func path(in rect: CGRect) -> Path { var path = Path() + let p = CGFloat(progress) - // We want the mask (the card back) to shrink towards the top-left - // as progress (the reveal) increases. - // A simple diagonal peel: - let p = CGFloat(1.0 - progress) + // Initial state: Full rectangle + path.addRect(rect) - path.move(to: .zero) - path.addLine(to: CGPoint(x: rect.width * p * 1.5, y: 0)) - path.addLine(to: CGPoint(x: 0, y: rect.height * p * 1.5)) - path.closeSubpath() + // Subtraction: The "peeled away" triangle + var peelPath = Path() + let size = rect.width * p * 2.0 // Scaling factor for the peel area + switch corner { + case .bottomRight: + peelPath.move(to: CGPoint(x: rect.width, y: rect.height)) + peelPath.addLine(to: CGPoint(x: rect.width - size, y: rect.height)) + peelPath.addLine(to: CGPoint(x: rect.width, y: rect.height - size)) + peelPath.closeSubpath() + case .bottomLeft: + peelPath.move(to: CGPoint(x: 0, y: rect.height)) + peelPath.addLine(to: CGPoint(x: size, y: rect.height)) + peelPath.addLine(to: CGPoint(x: 0, y: rect.height - size)) + peelPath.closeSubpath() + } + + // We use a "subtract" logic by drawing the full rect then "erasing" the peel + // In SwiftUI Shape, we just return the "remaining" area. + // For simplicity, we'll construct the remaining polygon. + + var remainingPath = Path() + switch corner { + case .bottomRight: + remainingPath.move(to: .zero) + remainingPath.addLine(to: CGPoint(x: rect.width, y: 0)) + remainingPath.addLine(to: CGPoint(x: rect.width, y: max(0, rect.height - size))) + remainingPath.addLine(to: CGPoint(x: max(0, rect.width - size), y: rect.height)) + remainingPath.addLine(to: CGPoint(x: 0, y: rect.height)) + remainingPath.closeSubpath() + case .bottomLeft: + remainingPath.move(to: .zero) + remainingPath.addLine(to: CGPoint(x: rect.width, y: 0)) + remainingPath.addLine(to: CGPoint(x: rect.width, y: rect.height)) + remainingPath.addLine(to: CGPoint(x: min(rect.width, size), y: rect.height)) + remainingPath.addLine(to: CGPoint(x: 0, y: max(0, rect.height - size))) + remainingPath.closeSubpath() + } + + return remainingPath + } +} + +/// The visual effect of the folded corner, including shadows and highlight. +struct FoldEffect: View { + let progress: Double + let corner: SqueezeCorner + let width: CGFloat + let height: CGFloat + + var body: some View { + let size = width * CGFloat(progress) * 2.0 + let foldWidth = size * 0.5 + + ZStack { + // Fold shadow cast on the front of the card + FoldShadow(size: size, corner: corner) + .opacity(0.3 * (1.0 - progress)) + + // The fold itself (the "back" of the peel) + FoldShape(size: size, corner: corner) + .fill( + LinearGradient( + colors: [.white.opacity(0.8), .gray.opacity(0.3)], + startPoint: corner == .bottomRight ? .bottomTrailing : .bottomLeading, + endPoint: corner == .bottomRight ? .topLeading : .topTrailing + ) + ) + .overlay( + FoldShape(size: size, corner: corner) + .stroke(Color.white.opacity(0.5), lineWidth: 0.5) + ) + } + } +} + +struct FoldShape: Shape { + let size: CGFloat + let corner: SqueezeCorner + + func path(in rect: CGRect) -> Path { + var path = Path() + // A triangle representing the folded over piece + switch corner { + case .bottomRight: + path.move(to: CGPoint(x: rect.width - size/2, y: rect.height - size/2)) + path.addLine(to: CGPoint(x: rect.width - size, y: rect.height)) + path.addLine(to: CGPoint(x: rect.width, y: rect.height - size)) + path.closeSubpath() + case .bottomLeft: + path.move(to: CGPoint(x: size/2, y: rect.height - size/2)) + path.addLine(to: CGPoint(x: size, y: rect.height)) + path.addLine(to: CGPoint(x: 0, y: rect.height - size)) + path.closeSubpath() + } return path } } -#Preview { - VStack(spacing: 40) { - SqueezeCardView(card: Card(suit: .spades, rank: .ace), width: 150, progress: 0.2) - SqueezeCardView(card: Card(suit: .hearts, rank: .king), width: 150, progress: 0.5) - SqueezeCardView(card: Card(suit: .diamonds, rank: .seven), width: 150, progress: 0.8) +struct FoldShadow: View { + let size: CGFloat + let corner: SqueezeCorner + + var body: some View { + FoldShape(size: size * 1.1, corner: corner) + .fill(Color.black) + .blur(radius: 4) + .offset(x: corner == .bottomRight ? -2 : 2, y: -2) + } +} + +#Preview { + ZStack { + Color.CasinoTable.backgroundDark.ignoresSafeArea() + + VStack(spacing: 60) { + SqueezeCardView(card: Card(suit: .spades, rank: .ace), width: 140, progress: 0.1, corner: .bottomRight) + SqueezeCardView(card: Card(suit: .hearts, rank: .king), width: 140, progress: 0.3, corner: .bottomRight) + SqueezeCardView(card: Card(suit: .diamonds, rank: .seven), width: 140, progress: 0.5, corner: .bottomLeft) + } } - .padding() - .background(Color.CasinoTable.backgroundDark) }