page curl

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
Matt Bruce 2026-01-21 17:03:46 -06:00
parent 2a74386b71
commit 833841dcdd
4 changed files with 277 additions and 51 deletions

View File

@ -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" : {

View File

@ -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)
}
PageCurlView(
card: card,
width: cardWidth,
onReveal: onReveal
)
.overlay {
if progress < 0.05 {
revealInstructionOverlay(text: String(localized: "PEEL"))
}
}
} else {
// Standard CardView (handles its own flip animation)
CardView(card: card, isFaceUp: isFaceUp, cardWidth: cardWidth)

View File

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

View File

@ -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))
// 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
}
}
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 {
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)
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)
}