diff --git a/ComputerSolitaire/Animation/DrawAnimationCoordinator.swift b/ComputerSolitaire/Animation/DrawAnimationCoordinator.swift index 3a0118b..ac42b53 100644 --- a/ComputerSolitaire/Animation/DrawAnimationCoordinator.swift +++ b/ComputerSolitaire/Animation/DrawAnimationCoordinator.swift @@ -15,14 +15,17 @@ enum DrawAnimationCoordinator { let cardIDs: Set let token: UUID let travelDuration: Double - let totalDelay: Double + /// Spring tail after the nominal travel time; the overlay comes down + /// once the cards have visibly settled. + let settleDuration: Double } static func makeDrawPlan( newCards: [Card], cardSize: CGSize, stockFrame: CGRect, - wasteFrame: CGRect + wasteFrame: CGRect, + fanSpacing: CGFloat ) -> Plan? { guard !newCards.isEmpty else { return nil } guard stockFrame != .zero, wasteFrame != .zero else { return nil } @@ -30,13 +33,16 @@ enum DrawAnimationCoordinator { let startPoint = CGPoint(x: stockFrame.midX, y: stockFrame.midY) let baseX = wasteFrame.minX + cardSize.width * 0.5 let baseY = wasteFrame.minY + cardSize.height * 0.5 + // One gesture: the packet leaves the stock together and each card + // flies straight to its own fan slot, flipping in the air on the way + // — so the spread opens up mid-flight and they land already fanned. let items = newCards.enumerated().map { index, card in DrawAnimationCard( id: card.id, card: card, start: startPoint, - end: CGPoint(x: baseX, y: baseY), - delay: 0.05 * Double(index) + end: CGPoint(x: baseX + fanSpacing * CGFloat(index), y: baseY), + delay: 0 ) } @@ -45,7 +51,7 @@ enum DrawAnimationCoordinator { cardIDs: Set(items.map(\.id)), token: UUID(), travelDuration: 0.32, - totalDelay: 0.05 * Double(max(0, newCards.count - 1)) + settleDuration: 0.12 ) } } diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/Contents.json b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/Contents.json new file mode 100644 index 0000000..6e96565 --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfClubs.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfClubs.imageset/Contents.json new file mode 100644 index 0000000..280a8d2 --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfClubs.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "jack-of-clubs.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfClubs.imageset/jack-of-clubs.png b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfClubs.imageset/jack-of-clubs.png new file mode 100644 index 0000000..253a904 Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfClubs.imageset/jack-of-clubs.png differ diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfDiamonds.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfDiamonds.imageset/Contents.json new file mode 100644 index 0000000..a612b15 --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfDiamonds.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "jack-of-diamonds.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfDiamonds.imageset/jack-of-diamonds.png b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfDiamonds.imageset/jack-of-diamonds.png new file mode 100644 index 0000000..8d64c9c Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfDiamonds.imageset/jack-of-diamonds.png differ diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfHearts.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfHearts.imageset/Contents.json new file mode 100644 index 0000000..0c6ddbb --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfHearts.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "jack-of-hearts.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfHearts.imageset/jack-of-hearts.png b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfHearts.imageset/jack-of-hearts.png new file mode 100644 index 0000000..33148ac Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfHearts.imageset/jack-of-hearts.png differ diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfSpades.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfSpades.imageset/Contents.json new file mode 100644 index 0000000..6f20b95 --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfSpades.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "jack-of-spades.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfSpades.imageset/jack-of-spades.png b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfSpades.imageset/jack-of-spades.png new file mode 100644 index 0000000..9a72620 Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/JackOfSpades.imageset/jack-of-spades.png differ diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfClubs.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfClubs.imageset/Contents.json new file mode 100644 index 0000000..2f7b3e3 --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfClubs.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "king-of-clubs.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfClubs.imageset/king-of-clubs.png b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfClubs.imageset/king-of-clubs.png new file mode 100644 index 0000000..b89cab5 Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfClubs.imageset/king-of-clubs.png differ diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfDiamonds.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfDiamonds.imageset/Contents.json new file mode 100644 index 0000000..ad93cb3 --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfDiamonds.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "king-of-diamonds.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfDiamonds.imageset/king-of-diamonds.png b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfDiamonds.imageset/king-of-diamonds.png new file mode 100644 index 0000000..e9e1a5e Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfDiamonds.imageset/king-of-diamonds.png differ diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfHearts.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfHearts.imageset/Contents.json new file mode 100644 index 0000000..c82e304 --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfHearts.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "king-of-hearts.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfHearts.imageset/king-of-hearts.png b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfHearts.imageset/king-of-hearts.png new file mode 100644 index 0000000..04f6cf2 Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfHearts.imageset/king-of-hearts.png differ diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfSpades.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfSpades.imageset/Contents.json new file mode 100644 index 0000000..f52bef0 --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfSpades.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "king-of-spades.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfSpades.imageset/king-of-spades.png b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfSpades.imageset/king-of-spades.png new file mode 100644 index 0000000..df04132 Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/KingOfSpades.imageset/king-of-spades.png differ diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfClubs.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfClubs.imageset/Contents.json new file mode 100644 index 0000000..43d6140 --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfClubs.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "queen-of-clubs.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfClubs.imageset/queen-of-clubs.png b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfClubs.imageset/queen-of-clubs.png new file mode 100644 index 0000000..bcb2c0c Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfClubs.imageset/queen-of-clubs.png differ diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfDiamonds.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfDiamonds.imageset/Contents.json new file mode 100644 index 0000000..f7c5d43 --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfDiamonds.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "queen-of-diamonds.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfDiamonds.imageset/queen-of-diamonds.png b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfDiamonds.imageset/queen-of-diamonds.png new file mode 100644 index 0000000..3f495c7 Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfDiamonds.imageset/queen-of-diamonds.png differ diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfHearts.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfHearts.imageset/Contents.json new file mode 100644 index 0000000..b4169eb --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfHearts.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "queen-of-hearts.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfHearts.imageset/queen-of-hearts.png b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfHearts.imageset/queen-of-hearts.png new file mode 100644 index 0000000..b6142ae Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfHearts.imageset/queen-of-hearts.png differ diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfSpades.imageset/Contents.json b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfSpades.imageset/Contents.json new file mode 100644 index 0000000..e8cc71f --- /dev/null +++ b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfSpades.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "queen-of-spades.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfSpades.imageset/queen-of-spades.png b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfSpades.imageset/queen-of-spades.png new file mode 100644 index 0000000..53a9e39 Binary files /dev/null and b/ComputerSolitaire/Assets.xcassets/CardArt/Simple/QueenOfSpades.imageset/queen-of-spades.png differ diff --git a/ComputerSolitaire/Views/Cards/CardView.swift b/ComputerSolitaire/Views/Cards/CardView.swift new file mode 100644 index 0000000..88d38d0 --- /dev/null +++ b/ComputerSolitaire/Views/Cards/CardView.swift @@ -0,0 +1,299 @@ +import SwiftUI + +// Shared card infrastructure: the style catalog, the environment plumbing, and +// the CardView dispatcher with behavior common to every card style (flip +// animation, tilt, selection scale, hint wiggle). Style-specific rendering +// lives in ClassicCardViews.swift, SimpleCardViews.swift, and PixelCardViews.swift. + +/// Display metadata a card style provides for the settings picker; each style +/// defines its own in its file under Styles/. +struct CardStyleInfo { + let title: String + let subtitle: String +} + +enum CardStyle: String, CaseIterable, Identifiable { + case classic + case simple + case pixel + + static let defaultValue: CardStyle = .classic + + var id: String { rawValue } + + /// The one dispatch point per style, alongside the view dispatch in CardView. + var info: CardStyleInfo { + switch self { + case .classic: ClassicCardStyle.info + case .simple: SimpleCardStyle.info + case .pixel: PixelCardStyle.info + } + } + + var title: String { info.title } + var subtitle: String { info.subtitle } +} + +/// Style-agnostic card back color identity. Each card style maps an identity +/// to its own palette (see PixelBackColorway); styles that haven't adopted the +/// setting yet simply ignore it. +struct CardBackColor: Identifiable, Equatable { + let id: String + let label: String + /// Representative color for the settings swatch. + let swatch: Color + + static let navy = CardBackColor( + id: "navy", label: "Navy", + swatch: Color(red: 0.21, green: 0.29, blue: 0.56) + ) + static let crimson = CardBackColor( + id: "crimson", label: "Crimson", + swatch: Color(red: 0.57, green: 0.20, blue: 0.22) + ) + static let forest = CardBackColor( + id: "forest", label: "Forest", + swatch: Color(red: 0.15, green: 0.40, blue: 0.25) + ) + static let plum = CardBackColor( + id: "plum", label: "Plum", + swatch: Color(red: 0.38, green: 0.21, blue: 0.53) + ) + + static let all: [CardBackColor] = [navy, crimson, forest, plum] + static let defaultValue: CardBackColor = .navy + + static func from(rawValue: String) -> CardBackColor { + all.first { $0.id == rawValue } ?? defaultValue + } +} + +private struct CardStyleKey: EnvironmentKey { + static let defaultValue: CardStyle = .defaultValue +} + +extension EnvironmentValues { + var cardStyle: CardStyle { + get { self[CardStyleKey.self] } + set { self[CardStyleKey.self] = newValue } + } +} + +enum CardTilt { + static let angleRange: ClosedRange = -2.0...2.0 +} + +/// Selection-dependent corner, border, and shadow treatment for card styles +/// that use the standard rounded-rectangle chrome. +struct CardChrome { + let cornerRadius: CGFloat + let borderColor: Color + let borderWidth: CGFloat + let shadowColor: Color + let shadowRadius: CGFloat + let shadowYOffset: CGFloat + + init(cardWidth: CGFloat, isSelected: Bool) { + cornerRadius = cardWidth * 0.12 + borderColor = isSelected ? Color.yellow.opacity(0.9) : Color.black.opacity(0.2) + borderWidth = isSelected ? 3 : 1 + shadowColor = Color.black.opacity(isSelected ? 0.35 : 0.2) + shadowRadius = isSelected ? 8 : 4 + shadowYOffset = isSelected ? 6 : 2 + } +} + +enum HintWiggleStyle { + static let angles: [Double] = [-1.4, 1.4, -0.8, 0.8, 0] + static let stepDuration: Double = 0.13 + static let stepSleepNanoseconds: UInt64 = 200_000_000 +} + +struct HintWiggleModifier: ViewModifier { + let token: UUID? + @State private var wiggleAngle: Double = 0 + @State private var wiggleTask: Task? + + func body(content: Content) -> some View { + content + .rotationEffect(.degrees(wiggleAngle)) + .onChange(of: token) { _, newToken in + if newToken == nil { + wiggleTask?.cancel() + wiggleAngle = 0 + return + } + startHintWiggle() + } + .onAppear { + if token != nil { + startHintWiggle() + } + } + .onDisappear { + wiggleTask?.cancel() + wiggleAngle = 0 + } + } + + private func startHintWiggle() { + wiggleTask?.cancel() + wiggleTask = Task { @MainActor in + for angle in HintWiggleStyle.angles { + if Task.isCancelled { return } + withAnimation(.easeInOut(duration: HintWiggleStyle.stepDuration)) { + wiggleAngle = angle + } + try? await Task.sleep(nanoseconds: HintWiggleStyle.stepSleepNanoseconds) + } + wiggleAngle = 0 + } + } +} + +extension View { + func hintWiggle(token: UUID?) -> some View { + modifier(HintWiggleModifier(token: token)) + } +} + +struct CardView: View { + let card: Card + let isSelected: Bool + let cardSize: CGSize + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hintWiggleToken: UUID? + let flipOnAppear: Bool + let flipDelay: Double + @State private var flipRotation: Double + @State private var tiltAngle: Double = 0 + @Environment(\.cardStyle) private var cardStyle + + init( + card: Card, + isSelected: Bool, + cardSize: CGSize, + isCardTiltEnabled: Bool, + cardTilts: Binding<[UUID: Double]>, + hintWiggleToken: UUID? = nil, + flipOnAppear: Bool = false, + flipDelay: Double = 0 + ) { + self.card = card + self.isSelected = isSelected + self.cardSize = cardSize + self.isCardTiltEnabled = isCardTiltEnabled + self._cardTilts = cardTilts + self.hintWiggleToken = hintWiggleToken + self.flipOnAppear = flipOnAppear + self.flipDelay = flipDelay + let startFaceDown = flipOnAppear && card.isFaceUp + _flipRotation = State(initialValue: startFaceDown ? 180 : (card.isFaceUp ? 0 : 180)) + } + + var body: some View { + let frontAngle = flipRotation + let backAngle = flipRotation - 180 + let frontOpacity = flipRotation < 90 ? 1.0 : 0.0 + let backOpacity = flipRotation < 90 ? 0.0 : 1.0 + + ZStack { + cardFrontView + .opacity(frontOpacity) + .rotation3DEffect(.degrees(frontAngle), axis: (x: 0, y: 1, z: 0), perspective: 0.7) + + cardBackView + .opacity(backOpacity) + .rotation3DEffect(.degrees(backAngle), axis: (x: 0, y: 1, z: 0), perspective: 0.7) + } + .frame(width: cardSize.width, height: cardSize.height) + .rotationEffect(.degrees(tiltAngle)) + .hintWiggle(token: hintWiggleToken) + .scaleEffect(isSelected ? 1.03 : 1) + .onChange(of: card.isFaceUp) { _, newValue in + withAnimation(.easeInOut(duration: 0.32)) { + flipRotation = newValue ? 0 : 180 + } + } + .onAppear { + if flipOnAppear, card.isFaceUp, flipRotation != 0 { + // easeOut so the rotation is front-loaded like the travel + // spring — the card visibly turns while it's moving fastest, + // not after it has mostly arrived. + withAnimation(.easeOut(duration: 0.3).delay(flipDelay)) { + flipRotation = 0 + } + } + // No animation: a card entering the hierarchy (dealt, revealed + // from under another, or restored) is already resting — animating + // 0 → tilt here reads as the card visibly re-tilting on reveal. + tiltAngle = isCardTiltEnabled ? resolvedTilt() : 0 + } + .onChange(of: cardTilts[card.id]) { _, newTilt in + guard isCardTiltEnabled, let newTilt else { return } + animateTilt(to: newTilt) + } + .onChange(of: isCardTiltEnabled) { _, enabled in + animateTilt(to: enabled ? resolvedTilt() : 0) + } + } + + // Returns the card's stored tilt, assigning a new random one on first use + // so a card keeps the same lean for as long as it stays in play. + private func resolvedTilt() -> Double { + if let existing = cardTilts[card.id] { + return existing + } + let newTilt = Double.random(in: CardTilt.angleRange) + cardTilts[card.id] = newTilt + return newTilt + } + + private func animateTilt(to target: Double) { + withAnimation(.easeOut(duration: 0.2)) { + tiltAngle = target + } + } + + @ViewBuilder + private var cardFrontView: some View { + switch cardStyle { + case .classic: + ClassicCardFrontView(card: card, cardSize: cardSize, isSelected: isSelected) + case .simple: + SimpleCardFrontView(card: card, cardSize: cardSize, isSelected: isSelected) + case .pixel: + PixelCardFrontView(card: card, cardSize: cardSize, isSelected: isSelected) + } + } + + @ViewBuilder + private var cardBackView: some View { + switch cardStyle { + case .classic: + ClassicCardBackView(cardSize: cardSize, isSelected: isSelected) + case .simple: + SimpleCardBackView(cardSize: cardSize, isSelected: isSelected) + case .pixel: + PixelCardBackView(cardSize: cardSize, isSelected: isSelected) + } + } +} + +/// Standalone card back (stock pile, deck art) rendered per the active style. +struct CardBackView: View { + let cardSize: CGSize + @Environment(\.cardStyle) private var cardStyle + + var body: some View { + switch cardStyle { + case .classic: + ClassicStandaloneCardBackView(cardSize: cardSize) + case .simple: + SimpleStandaloneCardBackView(cardSize: cardSize) + case .pixel: + PixelStandaloneCardBackView(cardSize: cardSize) + } + } +} diff --git a/ComputerSolitaire/Views/Cards/Styles/ClassicCardViews.swift b/ComputerSolitaire/Views/Cards/Styles/ClassicCardViews.swift new file mode 100644 index 0000000..9222829 --- /dev/null +++ b/ComputerSolitaire/Views/Cards/Styles/ClassicCardViews.swift @@ -0,0 +1,228 @@ +import SwiftUI + +// The "Classic" (parchment) card style. Shared card behavior and the style +// dispatcher live in CardView.swift. + +enum ClassicCardStyle { + static let info = CardStyleInfo(title: "Classic", subtitle: "Parchment") +} + +private enum ClassicPalette { + static let parchment = Color(red: 0.98, green: 0.96, blue: 0.91) + static let redInk = Color(red: 0.72, green: 0.16, blue: 0.18) + static let blackInk = Color(red: 0.12, green: 0.12, blue: 0.12) + static let ornament = Color(red: 0.66, green: 0.58, blue: 0.48) + + static func ink(for suit: Suit) -> Color { + suit.isRed ? redInk : blackInk + } +} + +/// The classic style's lacquer and trim for each CardBackColor identity. +/// Navy keeps the shipped values so existing decks look unchanged. +private struct ClassicBackColorway { + let backColorID: String + let lacquer: Color + let trim: Color + + static let navy = ClassicBackColorway( + backColorID: CardBackColor.navy.id, + lacquer: Color(red: 0.18, green: 0.26, blue: 0.52), + trim: Color(red: 0.78, green: 0.85, blue: 0.95) + ) + static let crimson = ClassicBackColorway( + backColorID: CardBackColor.crimson.id, + lacquer: Color(red: 0.48, green: 0.15, blue: 0.18), + trim: Color(red: 0.95, green: 0.82, blue: 0.82) + ) + static let forest = ClassicBackColorway( + backColorID: CardBackColor.forest.id, + lacquer: Color(red: 0.13, green: 0.34, blue: 0.22), + trim: Color(red: 0.80, green: 0.92, blue: 0.83) + ) + static let plum = ClassicBackColorway( + backColorID: CardBackColor.plum.id, + lacquer: Color(red: 0.32, green: 0.17, blue: 0.46), + trim: Color(red: 0.88, green: 0.81, blue: 0.95) + ) + + static let all: [ClassicBackColorway] = [navy, crimson, forest, plum] + + static func matching(_ back: CardBackColor) -> ClassicBackColorway { + all.first { $0.backColorID == back.id } ?? navy + } +} + +struct ClassicCardFrontView: View { + let card: Card + let cardSize: CGSize + let isSelected: Bool + + var body: some View { + let chrome = CardChrome(cardWidth: cardSize.width, isSelected: isSelected) + let inkColor = ClassicPalette.ink(for: card.suit) + // Rank in the top-left corner, suit in the top-right, mirrored 180 on + // the bottom edge. + let cornerMark = HStack(alignment: .firstTextBaseline, spacing: 0) { + Text(card.rank.label) + .font(.system(size: cardSize.width * 0.28, weight: .bold, design: .serif)) + Spacer(minLength: 0) + Image(systemName: card.suit.symbolName) + .font(.system(size: cardSize.width * 0.2, weight: .semibold)) + } + + ZStack { + ClassicCardBase(fill: ClassicPalette.parchment, chrome: chrome) + .overlay( + RoundedRectangle(cornerRadius: chrome.cornerRadius, style: .continuous) + .fill( + LinearGradient( + colors: [Color.white.opacity(0.55), Color.clear], + startPoint: UnitPoint.topLeading, + endPoint: UnitPoint.bottomTrailing + ) + ) + .blendMode(.softLight) + ) + .overlay( + RoundedRectangle(cornerRadius: chrome.cornerRadius * 0.92, style: .continuous) + .strokeBorder(ClassicPalette.ornament.opacity(0.6), lineWidth: 1) + .padding(cardSize.width * 0.06) + ) + .overlay( + RoundedRectangle(cornerRadius: chrome.cornerRadius * 0.78, style: .continuous) + .strokeBorder( + ClassicPalette.ornament.opacity(0.45), + style: StrokeStyle(lineWidth: 0.6, dash: [4, 3]) + ) + .padding(cardSize.width * 0.1) + ) + + cornerMark + .foregroundStyle(inkColor) + .padding(cardSize.width * 0.1) + .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.top) + + cornerMark + .foregroundStyle(inkColor) + .rotationEffect(.degrees(180)) + .padding(cardSize.width * 0.1) + .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.bottom) + + Image(systemName: card.suit.symbolName) + .font(.system(size: cardSize.width * 0.55, weight: .regular)) + .foregroundStyle(inkColor.opacity(0.12)) + .rotationEffect(.degrees(8)) + .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.center) + } + } +} + +struct ClassicCardBackView: View { + let cardSize: CGSize + let isSelected: Bool + + @AppStorage(SettingsKey.cardBackColor) private var cardBackColorRawValue = CardBackColor.defaultValue.id + + var body: some View { + let chrome = CardChrome(cardWidth: cardSize.width, isSelected: isSelected) + let colorway = ClassicBackColorway.matching(.from(rawValue: cardBackColorRawValue)) + + ZStack { + ClassicCardBase(fill: colorway.lacquer, chrome: chrome) + .overlay( + RoundedRectangle(cornerRadius: chrome.cornerRadius * 0.92, style: .continuous) + .strokeBorder(colorway.trim.opacity(0.55), lineWidth: 1) + .padding(cardSize.width * 0.08) + ) + .overlay( + RoundedRectangle(cornerRadius: chrome.cornerRadius * 0.8, style: .continuous) + .strokeBorder(colorway.trim.opacity(0.35), style: StrokeStyle(lineWidth: 0.6, dash: [5, 3])) + .padding(cardSize.width * 0.12) + ) + + ClassicCardBackPattern() + .padding(cardSize.width * 0.18) + } + } +} + +/// Standalone classic card back (stock pile, deck art). +struct ClassicStandaloneCardBackView: View { + let cardSize: CGSize + + @AppStorage(SettingsKey.cardBackColor) private var cardBackColorRawValue = CardBackColor.defaultValue.id + + var body: some View { + let cornerRadius = cardSize.width * 0.12 + let colorway = ClassicBackColorway.matching(.from(rawValue: cardBackColorRawValue)) + + ZStack { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(colorway.lacquer) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(colorway.trim.opacity(0.5), lineWidth: 1) + .padding(cardSize.width * 0.06) + ) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(colorway.trim.opacity(0.25), style: StrokeStyle(lineWidth: 0.6, dash: [5, 3])) + .padding(cardSize.width * 0.1) + ) + .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) + + ClassicCardBackPattern() + .padding(cardSize.width * 0.18) + } + .frame(width: cardSize.width, height: cardSize.height) + } +} + +private struct ClassicCardBase: View { + let fill: Color + let chrome: CardChrome + + var body: some View { + RoundedRectangle(cornerRadius: chrome.cornerRadius, style: .continuous) + .fill(fill) + .overlay( + RoundedRectangle(cornerRadius: chrome.cornerRadius, style: .continuous) + .stroke(chrome.borderColor, lineWidth: chrome.borderWidth) + ) + .shadow(color: chrome.shadowColor, radius: chrome.shadowRadius, x: 0, y: chrome.shadowYOffset) + } +} + +private struct ClassicCardBackPattern: View { + var body: some View { + GeometryReader { geometry in + let size = geometry.size + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color.white.opacity(0.18), lineWidth: 1) + Path { path in + let step: CGFloat = 10 + var x: CGFloat = 0 + while x < size.width { + path.move(to: CGPoint(x: x, y: 0)) + path.addLine(to: CGPoint(x: x, y: size.height)) + x += step + } + } + .stroke(Color.white.opacity(0.12), lineWidth: 1) + + Path { path in + let step: CGFloat = 10 + var y: CGFloat = 0 + while y < size.height { + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: size.width, y: y)) + y += step + } + } + .stroke(Color.white.opacity(0.08), lineWidth: 1) + } + } + } +} diff --git a/ComputerSolitaire/Views/Shared/PixelCardRenderer.swift b/ComputerSolitaire/Views/Cards/Styles/PixelCardViews.swift similarity index 89% rename from ComputerSolitaire/Views/Shared/PixelCardRenderer.swift rename to ComputerSolitaire/Views/Cards/Styles/PixelCardViews.swift index 182c155..603b569 100644 --- a/ComputerSolitaire/Views/Shared/PixelCardRenderer.swift +++ b/ComputerSolitaire/Views/Cards/Styles/PixelCardViews.swift @@ -1,5 +1,9 @@ import SwiftUI +enum PixelCardStyle { + static let info = CardStyleInfo(title: "Pixel", subtitle: "8-bit Retro") +} + // MARK: - Pixel Art Color Palette enum PixelPalette { @@ -25,16 +29,57 @@ enum PixelPalette { static let ermine = Color(red: 0.96, green: 0.95, blue: 0.92) static let accent = Color(red: 0.83, green: 0.22, blue: 0.25) - // Card back - static let backDeep = Color(red: 0.11, green: 0.16, blue: 0.37) - static let backMid = Color(red: 0.21, green: 0.29, blue: 0.56) - static let backBright = Color(red: 0.55, green: 0.64, blue: 0.90) - static let backGold = Color(red: 0.85, green: 0.68, blue: 0.25) - static func suitColor(for suit: Suit) -> Color { suit.isRed ? red : black } +} +/// The pixel style's tone ramp for each CardBackColor identity: deep field, +/// muted weave, mid intersections, bright frame. +struct PixelBackColorway { + let backColorID: String + let deep: Color + let muted: Color + let mid: Color + let bright: Color + + static let navy = PixelBackColorway( + backColorID: CardBackColor.navy.id, + deep: Color(red: 0.11, green: 0.16, blue: 0.37), + muted: Color(red: 0.18, green: 0.25, blue: 0.50), + mid: Color(red: 0.21, green: 0.29, blue: 0.56), + bright: Color(red: 0.55, green: 0.64, blue: 0.90) + ) + static let crimson = PixelBackColorway( + backColorID: CardBackColor.crimson.id, + deep: Color(red: 0.38, green: 0.10, blue: 0.12), + muted: Color(red: 0.50, green: 0.16, blue: 0.18), + mid: Color(red: 0.57, green: 0.20, blue: 0.22), + bright: Color(red: 0.92, green: 0.58, blue: 0.58) + ) + static let forest = PixelBackColorway( + backColorID: CardBackColor.forest.id, + deep: Color(red: 0.07, green: 0.24, blue: 0.14), + muted: Color(red: 0.12, green: 0.34, blue: 0.21), + mid: Color(red: 0.15, green: 0.40, blue: 0.25), + bright: Color(red: 0.55, green: 0.80, blue: 0.62) + ) + static let plum = PixelBackColorway( + backColorID: CardBackColor.plum.id, + deep: Color(red: 0.24, green: 0.11, blue: 0.34), + muted: Color(red: 0.33, green: 0.17, blue: 0.46), + mid: Color(red: 0.38, green: 0.21, blue: 0.53), + bright: Color(red: 0.74, green: 0.60, blue: 0.90) + ) + + static let all: [PixelBackColorway] = [navy, crimson, forest, plum] + + static func matching(_ back: CardBackColor) -> PixelBackColorway { + all.first { $0.backColorID == back.id } ?? navy + } +} + +extension PixelPalette { static func suitHighlight(for suit: Suit) -> Color { suit.isRed ? redLight : blackLight } @@ -649,35 +694,40 @@ enum PixelCardArt { // MARK: Back - static func drawBack(in context: GraphicsContext, size: CGSize, unit: CGFloat) { + /// Woven-lattice card back: a bright single-pixel frame around a diagonal + /// weave in the colorway's muted tone with mid-tone intersections. + static func drawBack( + in context: GraphicsContext, size: CGSize, unit: CGFloat, + colorway: PixelBackColorway = .navy + ) { let gridH = size.height / unit let lastRow = Int(gridH.rounded(.down)) - 3 // Inner bright frame, one unit thick, inset 2 from the edge. - let frame = PixelPalette.backBright + let frame = colorway.bright fillCells(context, x: 2, y: 2, w: gridWidth - 4, h: 1, unit: unit, color: frame) fillCells(context, x: 2, y: gridH - 3, w: gridWidth - 4, h: 1, unit: unit, color: frame) fillCells(context, x: 2, y: 3, w: 1, h: gridH - 6, unit: unit, color: frame) fillCells(context, x: gridWidth - 3, y: 3, w: 1, h: gridH - 6, unit: unit, color: frame) - // Diamond trellis field, phase-locked to the card center. + // Diagonal weave, phase-locked to the card center. let centerX = Int(gridWidth) / 2 let centerYCell = Int((gridH / 2).rounded(.down)) for cy in 4...(lastRow - 1) { for cx in 4...Int(gridWidth) - 5 { let sum = (cx - centerX) + (cy - centerYCell) let diff = (cx - centerX) - (cy - centerYCell) - let onSum = sum % 6 == 0 - let onDiff = diff % 6 == 0 + let onSum = ((sum % 6) + 6) % 6 == 0 + let onDiff = ((diff % 6) + 6) % 6 == 0 if onSum && onDiff { fillCells( context, x: CGFloat(cx), y: CGFloat(cy), w: 1, h: 1, - unit: unit, color: PixelPalette.backGold + unit: unit, color: colorway.mid ) } else if onSum || onDiff { fillCells( context, x: CGFloat(cx), y: CGFloat(cy), w: 1, h: 1, - unit: unit, color: PixelPalette.backMid + unit: unit, color: colorway.muted ) } } @@ -724,6 +774,8 @@ struct PixelCardBackView: View { let cardSize: CGSize let isSelected: Bool + @AppStorage(SettingsKey.cardBackColor) private var cardBackColorRawValue = CardBackColor.defaultValue.id + init(cardSize: CGSize, isSelected: Bool = false) { self.cardSize = cardSize self.isSelected = isSelected @@ -734,11 +786,12 @@ struct PixelCardBackView: View { let shape = PixelCardShape(px: unit) let borderColor = isSelected ? Color.yellow.opacity(0.88) : PixelPalette.outline let borderWidth = isSelected ? max(2, unit * 1.6) : max(0.8, unit) + let colorway = PixelBackColorway.matching(.from(rawValue: cardBackColorRawValue)) ZStack { - shape.fill(PixelPalette.backDeep, style: FillStyle(antialiased: false)) + shape.fill(colorway.deep, style: FillStyle(antialiased: false)) Canvas { context, size in - PixelCardArt.drawBack(in: context, size: size, unit: unit) + PixelCardArt.drawBack(in: context, size: size, unit: unit, colorway: colorway) } } .frame(width: cardSize.width, height: cardSize.height) diff --git a/ComputerSolitaire/Views/Cards/Styles/SimpleCardViews.swift b/ComputerSolitaire/Views/Cards/Styles/SimpleCardViews.swift new file mode 100644 index 0000000..034b3bc --- /dev/null +++ b/ComputerSolitaire/Views/Cards/Styles/SimpleCardViews.swift @@ -0,0 +1,180 @@ +import SwiftUI + +// The "Simple" card style: a clean white face with the rank at the top and a +// single full-opacity suit glyph in the center. No mirrored bottom marks. +// Shared card behavior and the style dispatcher live in CardView.swift. + +enum SimpleCardStyle { + static let info = CardStyleInfo(title: "Simple", subtitle: "Clean") +} + +/// Royal artwork anchored to the bottom-right corner of the face, replacing +/// the center suit glyph. Cards without art keep the plain glyph face. +private enum SimpleCardArt { + static func imageName(for card: Card) -> String? { + switch (card.rank, card.suit) { + case (.queen, .hearts): "Simple/QueenOfHearts" + case (.queen, .clubs): "Simple/QueenOfClubs" + case (.queen, .spades): "Simple/QueenOfSpades" + case (.queen, .diamonds): "Simple/QueenOfDiamonds" + case (.jack, .hearts): "Simple/JackOfHearts" + case (.jack, .clubs): "Simple/JackOfClubs" + case (.jack, .spades): "Simple/JackOfSpades" + case (.jack, .diamonds): "Simple/JackOfDiamonds" + case (.king, .hearts): "Simple/KingOfHearts" + case (.king, .clubs): "Simple/KingOfClubs" + case (.king, .spades): "Simple/KingOfSpades" + case (.king, .diamonds): "Simple/KingOfDiamonds" + default: nil + } + } +} + +private enum SimplePalette { + static let face = Color.white + static let red = Color(red: 0.80, green: 0.12, blue: 0.16) + static let black = Color(red: 0.10, green: 0.10, blue: 0.12) + static let backTrim = Color.white.opacity(0.7) + + static func ink(for suit: Suit) -> Color { + suit.isRed ? red : black + } +} + +/// The simple style's back tint for each CardBackColor identity. +private struct SimpleBackColorway { + let backColorID: String + let base: Color + + static let navy = SimpleBackColorway( + backColorID: CardBackColor.navy.id, + base: Color(red: 0.19, green: 0.28, blue: 0.52) + ) + static let crimson = SimpleBackColorway( + backColorID: CardBackColor.crimson.id, + base: Color(red: 0.55, green: 0.19, blue: 0.21) + ) + static let forest = SimpleBackColorway( + backColorID: CardBackColor.forest.id, + base: Color(red: 0.16, green: 0.40, blue: 0.28) + ) + static let plum = SimpleBackColorway( + backColorID: CardBackColor.plum.id, + base: Color(red: 0.36, green: 0.20, blue: 0.50) + ) + + static let all: [SimpleBackColorway] = [navy, crimson, forest, plum] + + static func matching(_ back: CardBackColor) -> SimpleBackColorway { + all.first { $0.backColorID == back.id } ?? navy + } +} + +struct SimpleCardFrontView: View { + let card: Card + let cardSize: CGSize + let isSelected: Bool + + var body: some View { + let chrome = CardChrome(cardWidth: cardSize.width, isSelected: isSelected) + let inkColor = SimplePalette.ink(for: card.suit) + + ZStack { + SimpleCardBase(fill: SimplePalette.face, chrome: chrome) + + HStack(alignment: .firstTextBaseline, spacing: 0) { + Text(card.rank.label) + .font(.custom("Charter-Bold", size: cardSize.width * 0.27)) + Spacer(minLength: 0) + Image(systemName: card.suit.symbolName) + .font(.system(size: cardSize.width * 0.22, weight: .semibold)) + } + .foregroundStyle(inkColor) + .padding(cardSize.width * 0.08) + .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.top) + + if let artName = SimpleCardArt.imageName(for: card) { + // Royal figure planted in the bottom-right corner, dress and + // trailing arm trimmed by the card bounds; sized to stay + // clear of the top marks. Jack artwork carries extra headroom + // in its canvas, so it gets a small extra nudge into the + // corner to line up with the kings and queens. + let isJack = card.rank == .jack + Image(artName) + .resizable() + .scaledToFit() + .frame(width: cardSize.width * 0.88) + .offset(x: cardSize.width * (isJack ? 0.19 : 0.17), + y: cardSize.width * (isJack ? 0.36 : 0.32)) + .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.bottomTrailing) + .clipShape(RoundedRectangle(cornerRadius: chrome.cornerRadius, style: .continuous)) + } else { + // Optically centered in the region below the top marks, not + // the full card, so the face doesn't read bottom-heavy. + Image(systemName: card.suit.symbolName) + .font(.system(size: cardSize.width * 0.56, weight: .regular)) + .foregroundStyle(inkColor) + .offset(y: cardSize.width * 0.14) + .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.center) + } + } + } +} + +struct SimpleCardBackView: View { + let cardSize: CGSize + let isSelected: Bool + + @AppStorage(SettingsKey.cardBackColor) private var cardBackColorRawValue = CardBackColor.defaultValue.id + + var body: some View { + let chrome = CardChrome(cardWidth: cardSize.width, isSelected: isSelected) + let colorway = SimpleBackColorway.matching(.from(rawValue: cardBackColorRawValue)) + + ZStack { + SimpleCardBase(fill: colorway.base, chrome: chrome) + .overlay( + RoundedRectangle(cornerRadius: chrome.cornerRadius * 0.85, style: .continuous) + .strokeBorder(SimplePalette.backTrim, lineWidth: 1.5) + .padding(cardSize.width * 0.09) + ) + } + } +} + +/// Standalone simple card back (stock pile, deck art). +struct SimpleStandaloneCardBackView: View { + let cardSize: CGSize + + @AppStorage(SettingsKey.cardBackColor) private var cardBackColorRawValue = CardBackColor.defaultValue.id + + var body: some View { + let cornerRadius = cardSize.width * 0.12 + let colorway = SimpleBackColorway.matching(.from(rawValue: cardBackColorRawValue)) + + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(colorway.base) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius * 0.85, style: .continuous) + .strokeBorder(SimplePalette.backTrim, lineWidth: 1.5) + .padding(cardSize.width * 0.09) + ) + .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) + .frame(width: cardSize.width, height: cardSize.height) + } +} + +private struct SimpleCardBase: View { + let fill: Color + let chrome: CardChrome + + var body: some View { + RoundedRectangle(cornerRadius: chrome.cornerRadius, style: .continuous) + .fill(fill) + .overlay( + RoundedRectangle(cornerRadius: chrome.cornerRadius, style: .continuous) + .stroke(chrome.borderColor, lineWidth: chrome.borderWidth) + ) + .shadow(color: chrome.shadowColor, radius: chrome.shadowRadius, x: 0, y: chrome.shadowYOffset) + } +} diff --git a/ComputerSolitaire/Views/SettingsView.swift b/ComputerSolitaire/Views/SettingsView.swift index d445213..dbfa8be 100644 --- a/ComputerSolitaire/Views/SettingsView.swift +++ b/ComputerSolitaire/Views/SettingsView.swift @@ -44,27 +44,6 @@ enum TableBackgroundColor: String, CaseIterable, Identifiable { } } -enum CardStyle: String, CaseIterable, Identifiable { - case classic - case pixel - - var id: String { rawValue } - - var title: String { - switch self { - case .classic: return "Classic" - case .pixel: return "Pixel" - } - } - - var subtitle: String { - switch self { - case .classic: return "Parchment" - case .pixel: return "8-bit Retro" - } - } -} - enum SettingsKey { static let cardTiltEnabled = "settings.cardTiltEnabled" static let gameVariant = "settings.gameVariant" @@ -74,6 +53,7 @@ enum SettingsKey { static let soundEffectsEnabled = "settings.soundEffectsEnabled" static let showHintButton = "settings.showHintButton" static let cardStyle = "settings.cardStyle" + static let cardBackColor = "settings.cardBackColor" } struct SettingsView: View { @@ -86,7 +66,8 @@ struct SettingsView: View { @AppStorage(SettingsKey.feltEffectEnabled) private var isFeltEffectEnabled = true @AppStorage(SettingsKey.soundEffectsEnabled) private var isSoundEffectsEnabled = true @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = true - @AppStorage(SettingsKey.cardStyle) private var cardStyleRawValue = CardStyle.classic.rawValue + @AppStorage(SettingsKey.cardStyle) private var cardStyleRawValue = CardStyle.defaultValue.rawValue + @AppStorage(SettingsKey.cardBackColor) private var cardBackColorRawValue = CardBackColor.defaultValue.id #if os(iOS) @State private var selectedAppIcon = AppIcon.current() @State private var isShowingAppIconPicker = false @@ -194,6 +175,21 @@ struct SettingsView: View { Text("Adds a subtle organic angle to each card.") } .toggleStyle(.switch) + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Card back color") + Spacer() + Text(CardBackColor.from(rawValue: cardBackColorRawValue).label) + .foregroundStyle(.secondary) + } + HStack(spacing: 8) { + ForEach(CardBackColor.all) { option in + cardBackSwatch(option) + } + Spacer() + } + } + .padding(.vertical, 4) } header: { Text("Cards") } @@ -316,8 +312,10 @@ struct SettingsView: View { } private func cardStyleCard(_ style: CardStyle) -> some View { - Button { - guard cardStyleRawValue != style.rawValue else { return } + let isSelected = cardStyleRawValue == style.rawValue + + return Button { + guard !isSelected else { return } HapticManager.shared.play(.settingsSelection) withAnimation(.smooth(duration: 0.3)) { cardStyleRawValue = style.rawValue @@ -335,36 +333,30 @@ struct SettingsView: View { .foregroundStyle(.secondary) } } - .settingsChip(isSelected: cardStyleRawValue == style.rawValue) + .settingsChip(isSelected: isSelected) } .buttonStyle(.plain) - .accessibilityAddTraits(cardStyleRawValue == style.rawValue ? .isSelected : []) + .accessibilityAddTraits(isSelected ? .isSelected : []) } @ViewBuilder private func cardStylePreview(_ style: CardStyle) -> some View { switch style { case .classic: - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color(red: 0.98, green: 0.96, blue: 0.91)) - .overlay( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .stroke(Color.black.opacity(0.2), lineWidth: 0.5) - ) - .overlay( - VStack(alignment: .leading, spacing: 1) { - Text("A") - .font(.system(size: 11, weight: .bold, design: .serif)) - Image(systemName: "suit.spade.fill") - .font(.system(size: 8)) - } - .foregroundStyle(Color(red: 0.12, green: 0.12, blue: 0.12)) - .padding(4), - alignment: .topLeading - ) + ClassicCardFrontView( + card: Card(suit: .hearts, rank: .queen, isFaceUp: true), + cardSize: CGSize(width: 44, height: 64), + isSelected: false + ) + case .simple: + SimpleCardFrontView( + card: Card(suit: .hearts, rank: .queen, isFaceUp: true), + cardSize: CGSize(width: 44, height: 64), + isSelected: false + ) case .pixel: PixelCardFrontView( - card: Card(suit: .hearts, rank: .king, isFaceUp: true), + card: Card(suit: .hearts, rank: .queen, isFaceUp: true), cardSize: CGSize(width: 44, height: 64), isSelected: false ) @@ -402,6 +394,37 @@ struct SettingsView: View { .accessibilityLabel(option.label) .accessibilityAddTraits(isSelected ? .isSelected : []) } + + private func cardBackSwatch(_ option: CardBackColor) -> some View { + let isSelected = cardBackColorRawValue == option.id + + return Button { + guard !isSelected else { return } + HapticManager.shared.play(.settingsSelection) + cardBackColorRawValue = option.id + } label: { + Circle() + .fill(option.swatch) + .overlay { + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } + } + .overlay { + Circle() + .stroke( + isSelected ? Color.accentColor : Color.primary.opacity(0.18), + lineWidth: isSelected ? 2.5 : 1 + ) + } + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel(option.label) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } } private extension View { diff --git a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift index 52859fd..66ec172 100644 --- a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift @@ -4,6 +4,8 @@ import Observation struct DrawOverlayView: View { let cards: [DrawAnimationCard] let cardSize: CGSize + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] var body: some View { ForEach(cards) { item in @@ -12,7 +14,9 @@ struct DrawOverlayView: View { cardSize: cardSize, start: item.start, end: item.end, - delay: item.delay + delay: item.delay, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts ) } .allowsHitTesting(false) @@ -71,6 +75,8 @@ private struct DrawOverlayCardView: View { let start: CGPoint let end: CGPoint let delay: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] @State private var progress: CGFloat = 0 var body: some View { @@ -80,10 +86,13 @@ private struct DrawOverlayCardView: View { card: card, isSelected: false, cardSize: cardSize, - isCardTiltEnabled: false, - cardTilts: .constant([:]), + // Shares the real card's tilt so the resting pose is already + // there when the overlay hands off — no post-landing tilt pop. + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, flipOnAppear: true, - flipDelay: delay + 0.05 + // The packet flips in the air while it travels and spreads. + flipDelay: delay ) .position(x: currentX, y: currentY) .onAppear { diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index 2786a9e..b9048e7 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -1,19 +1,6 @@ import SwiftUI import Observation -// MARK: - CardStyle Environment Key - -private struct CardStyleKey: EnvironmentKey { - static let defaultValue: CardStyle = .classic -} - -extension EnvironmentValues { - var cardStyle: CardStyle { - get { self[CardStyleKey.self] } - set { self[CardStyleKey.self] = newValue } - } -} - enum Layout { struct Metrics { let horizontalPadding: CGFloat @@ -24,6 +11,44 @@ enum Layout { let tableauFaceDownOffset: CGFloat let tableauFaceUpOffset: CGFloat let wasteFanSpacing: CGFloat + let tableauMaxHeight: CGFloat + } + + /// Estimated height of HeaderView (stat tiles + padding); only feeds the + /// tableau-height budget, so an approximation is fine. + private static let headerHeightEstimate: CGFloat = 66 + + /// Worst-case Klondike pile: 6 face-down cards under a full K–A run. + private static let maxFaceDownGaps: CGFloat = 6 + private static let maxFaceUpGaps: CGFloat = 12 + + /// The pile the layout commits to displaying at natural spacing; cards + /// are sized so this depth fits the board height. The one design knob: + /// deeper commitment means smaller cards on short screens. Piles deeper + /// than this compress their spacing as before. + private static let readableFaceDownGaps: CGFloat = 4 + private static let readableFaceUpGaps: CGFloat = 10 + + /// Height-fitting never pushes cards below this width; on screens too + /// short to honor the readable depth at a usable card size (phone + /// landscape), spacing compression takes over instead. + private static let minHeightFittedCardWidth: CGFloat = 88 + + /// The largest card whose top row plus a readable-depth pile fit the + /// board height at natural spacing. Derived from the same chrome and + /// spacing fractions the layout actually uses, so it holds on any screen. + private static func heightFittedCardWidth( + boardHeight: CGFloat, + verticalPadding: CGFloat, + rowSpacing: CGFloat, + faceDownFraction: CGFloat, + faceUpFraction: CGFloat + ) -> CGFloat { + let chrome = (verticalPadding * 2) + headerHeightEstimate + (rowSpacing * 2) + // Top-row card + pile base card + gaps, in units of card height. + let heightUnits = 2 + (readableFaceDownGaps * faceDownFraction) + (readableFaceUpGaps * faceUpFraction) + let cardHeight = (boardHeight - chrome) / heightUnits + return max(minHeightFittedCardWidth, cardHeight / 1.45) } static func metrics( @@ -37,27 +62,53 @@ enum Layout { let isCompactBoard = boardWidth <= 430 let isMediumBoard = boardWidth > 430 && boardWidth < 760 let isPadLandscape = isRegularWidth && boardSize.width > boardSize.height + // Eight-column boards (FreeCell) on phones need tighter chrome so the + // extra column doesn't shrink every card. + let isDenseBoard = isCompactBoard && columnCount >= 8 - let horizontalPadding: CGFloat = isCompactBoard ? 12 : (isMediumBoard ? 14 : 24) + let horizontalPadding: CGFloat = isDenseBoard ? 8 : (isCompactBoard ? 12 : (isMediumBoard ? 14 : 24)) let verticalPadding: CGFloat = isPadLandscape ? 12 : (isCompactBoard ? 16 : 24) let rowSpacing: CGFloat = isPadLandscape ? 16 : (isCompactBoard ? 16 : 24) - let columnSpacing: CGFloat = isCompactBoard ? 8 : (isMediumBoard ? 10 : 18) + let columnSpacing: CGFloat = isDenseBoard ? 5 : (isCompactBoard ? 8 : (isMediumBoard ? 10 : 18)) + + let faceDownFraction: CGFloat = isCompactBoard ? 0.16 : 0.18 + let faceUpFraction: CGFloat = isCompactBoard ? 0.24 : 0.28 + let landscapeOffsetScale: CGFloat = isPadLandscape ? 0.8 : 1 let usableWidth = max(0, boardWidth - (horizontalPadding * 2)) let fittedCardWidth = floor((usableWidth - (columnSpacing * CGFloat(columnCount - 1))) / CGFloat(columnCount)) let maxCardWidth: CGFloat = isPadLandscape ? 112 : (boardWidth < 760 ? 96 : 120) - let cardWidth = max(32, min(maxCardWidth, fittedCardWidth)) + let heightFittedWidth = Self.heightFittedCardWidth( + boardHeight: boardSize.height, + verticalPadding: verticalPadding, + rowSpacing: rowSpacing, + faceDownFraction: faceDownFraction * landscapeOffsetScale, + faceUpFraction: faceUpFraction * landscapeOffsetScale + ) + let cardWidth = max(32, min(maxCardWidth, fittedCardWidth, heightFittedWidth)) let cardSize = CGSize(width: cardWidth, height: cardWidth * 1.45) - let baseFaceDownOffset = max(isCompactBoard ? 10 : 16, cardSize.height * (isCompactBoard ? 0.16 : 0.18)) - let baseFaceUpOffset = max(isCompactBoard ? 14 : 22, cardSize.height * (isCompactBoard ? 0.24 : 0.28)) + let tableauMaxHeight = tableauHeightBudget( + boardHeight: boardSize.height, + verticalPadding: verticalPadding, + rowSpacing: rowSpacing, + cardHeight: cardSize.height + ) + + let baseFaceDownOffset = max(isCompactBoard ? 10 : 16, cardSize.height * faceDownFraction) + let baseFaceUpOffset = max(isCompactBoard ? 14 : 22, cardSize.height * faceUpFraction) let faceUpOffset: CGFloat let faceDownOffset: CGFloat if isPadLandscape { - let landscapeOffsetScale: CGFloat = 0.72 faceUpOffset = max(22, baseFaceUpOffset * landscapeOffsetScale) faceDownOffset = max(14, baseFaceDownOffset * landscapeOffsetScale) + } else if isCompactBoard && boardSize.height > boardSize.width { + // Portrait phones have far more height than the width-fitted cards + // use; spread the worst-case pile into it, capped for readability. + let fittedFaceUp = (tableauMaxHeight - cardSize.height - maxFaceDownGaps * baseFaceDownOffset) / maxFaceUpGaps + faceUpOffset = min(max(baseFaceUpOffset, fittedFaceUp), cardSize.height * 0.38) + faceDownOffset = baseFaceDownOffset } else { faceUpOffset = baseFaceUpOffset faceDownOffset = baseFaceDownOffset @@ -73,7 +124,8 @@ enum Layout { cardSize: cardSize, tableauFaceDownOffset: faceDownOffset, tableauFaceUpOffset: faceUpOffset, - wasteFanSpacing: wasteFanSpacing + wasteFanSpacing: wasteFanSpacing, + tableauMaxHeight: tableauMaxHeight ) #else let horizontalPadding = min(24, max(14, boardWidth * 0.018)) @@ -81,14 +133,33 @@ enum Layout { let columnSpacing = min(18, max(10, boardWidth * 0.013)) let rowSpacing = min(22, max(14, columnSpacing + 4)) + let faceDownFraction: CGFloat = 0.18 + let faceUpFraction: CGFloat = 0.26 + let usableWidth = max(0, boardWidth - (horizontalPadding * 2)) let fittedCardWidth = floor((usableWidth - (columnSpacing * CGFloat(columnCount - 1))) / CGFloat(columnCount)) let maxCardWidth = min(124, max(88, boardWidth * 0.095)) - let cardWidth = max(52, min(maxCardWidth, fittedCardWidth)) + let heightFittedWidth = heightFittedCardWidth( + boardHeight: boardSize.height, + verticalPadding: verticalPadding, + rowSpacing: rowSpacing, + faceDownFraction: faceDownFraction, + faceUpFraction: faceUpFraction + ) + // Floor low enough that 8 FreeCell columns fit at the minimum window + // width, which only accommodates 7 Klondike columns at 52pt. + let cardWidth = max(40, min(maxCardWidth, fittedCardWidth, heightFittedWidth)) let cardSize = CGSize(width: cardWidth, height: cardWidth * 1.45) - let faceDownOffset = max(13, cardSize.height * 0.18) - let faceUpOffset = max(18, cardSize.height * 0.26) + let tableauMaxHeight = tableauHeightBudget( + boardHeight: boardSize.height, + verticalPadding: verticalPadding, + rowSpacing: rowSpacing, + cardHeight: cardSize.height + ) + + let faceDownOffset = max(13, cardSize.height * faceDownFraction) + let faceUpOffset = max(18, cardSize.height * faceUpFraction) let wasteFanSpacing = cardSize.width * (boardWidth < 760 ? 0.2 : 0.25) return Metrics( @@ -99,14 +170,21 @@ enum Layout { cardSize: cardSize, tableauFaceDownOffset: faceDownOffset, tableauFaceUpOffset: faceUpOffset, - wasteFanSpacing: wasteFanSpacing + wasteFanSpacing: wasteFanSpacing, + tableauMaxHeight: tableauMaxHeight ) #endif } -} -enum CardTilt { - static let angleRange: ClosedRange = -2.0...2.0 + private static func tableauHeightBudget( + boardHeight: CGFloat, + verticalPadding: CGFloat, + rowSpacing: CGFloat, + cardHeight: CGFloat + ) -> CGFloat { + let chrome = (verticalPadding * 2) + headerHeightEstimate + (rowSpacing * 2) + cardHeight + return max(cardHeight * 2, boardHeight - chrome) + } } struct HeaderView: View { @@ -273,6 +351,7 @@ struct TableauRowView: View { let columnSpacing: CGFloat let faceDownOffset: CGFloat let faceUpOffset: CGFloat + let maxPileHeight: CGFloat let activeTarget: DropTarget? let hintedTarget: DropTarget? let hintHighlightOpacity: Double @@ -292,6 +371,7 @@ struct TableauRowView: View { cardSize: cardSize, faceDownOffset: faceDownOffset, faceUpOffset: faceUpOffset, + maxPileHeight: maxPileHeight, isTargeted: activeTarget == .tableau(index), isHintTargeted: hintedTarget == .tableau(index), hintHighlightOpacity: hintHighlightOpacity, @@ -411,6 +491,7 @@ struct TableauPileView: View { let cardSize: CGSize let faceDownOffset: CGFloat let faceUpOffset: CGFloat + let maxPileHeight: CGFloat let isTargeted: Bool let isHintTargeted: Bool let hintHighlightOpacity: Double @@ -549,426 +630,21 @@ struct TableauPileView: View { } } + // Compress this pile's spread evenly when it would overflow the board. + let maxTopOffset = maxPileHeight - cardSize.height + if let last = yOffsets.last, last > maxTopOffset, maxTopOffset > 0 { + let scale = maxTopOffset / last + yOffsets = yOffsets.map { $0 * scale } + } + return yOffsets } private func dropYOffset(for pile: [Card], yOffsets: [CGFloat]) -> CGFloat { guard let lastCard = pile.last, let lastYOffset = yOffsets.last else { return 0 } - return lastYOffset + (lastCard.isFaceUp ? faceUpOffset : faceDownOffset) - } -} - -enum HintWiggleStyle { - static let angles: [Double] = [-1.4, 1.4, -0.8, 0.8, 0] - static let stepDuration: Double = 0.13 - static let stepSleepNanoseconds: UInt64 = 200_000_000 -} - -struct HintWiggleModifier: ViewModifier { - let token: UUID? - @State private var wiggleAngle: Double = 0 - @State private var wiggleTask: Task? - - func body(content: Content) -> some View { - content - .rotationEffect(.degrees(wiggleAngle)) - .onChange(of: token) { _, newToken in - if newToken == nil { - wiggleTask?.cancel() - wiggleAngle = 0 - return - } - startHintWiggle() - } - .onAppear { - if token != nil { - startHintWiggle() - } - } - .onDisappear { - wiggleTask?.cancel() - wiggleAngle = 0 - } - } - - private func startHintWiggle() { - wiggleTask?.cancel() - wiggleTask = Task { @MainActor in - for angle in HintWiggleStyle.angles { - if Task.isCancelled { return } - withAnimation(.easeInOut(duration: HintWiggleStyle.stepDuration)) { - wiggleAngle = angle - } - try? await Task.sleep(nanoseconds: HintWiggleStyle.stepSleepNanoseconds) - } - wiggleAngle = 0 - } - } -} - -extension View { - func hintWiggle(token: UUID?) -> some View { - modifier(HintWiggleModifier(token: token)) - } -} - -struct CardView: View { - let card: Card - let isSelected: Bool - let cardSize: CGSize - let isCardTiltEnabled: Bool - @Binding var cardTilts: [UUID: Double] - let hintWiggleToken: UUID? - let flipOnAppear: Bool - let flipDelay: Double - @State private var flipRotation: Double - @State private var tiltAngle: Double = 0 - @Environment(\.cardStyle) private var cardStyle - - init( - card: Card, - isSelected: Bool, - cardSize: CGSize, - isCardTiltEnabled: Bool, - cardTilts: Binding<[UUID: Double]>, - hintWiggleToken: UUID? = nil, - flipOnAppear: Bool = false, - flipDelay: Double = 0 - ) { - self.card = card - self.isSelected = isSelected - self.cardSize = cardSize - self.isCardTiltEnabled = isCardTiltEnabled - self._cardTilts = cardTilts - self.hintWiggleToken = hintWiggleToken - self.flipOnAppear = flipOnAppear - self.flipDelay = flipDelay - let startFaceDown = flipOnAppear && card.isFaceUp - _flipRotation = State(initialValue: startFaceDown ? 180 : (card.isFaceUp ? 0 : 180)) - } - - var body: some View { - let cornerRadius = cardSize.width * 0.12 - let borderColor = isSelected ? Color.yellow.opacity(0.9) : Color.black.opacity(0.2) - let borderWidth: CGFloat = isSelected ? 3 : 1 - let shadowColor = Color.black.opacity(isSelected ? 0.35 : 0.2) - let shadowRadius: CGFloat = isSelected ? 8 : 4 - let shadowYOffset: CGFloat = isSelected ? 6 : 2 - let frontAngle = flipRotation - let backAngle = flipRotation - 180 - let frontOpacity = flipRotation < 90 ? 1.0 : 0.0 - let backOpacity = flipRotation < 90 ? 0.0 : 1.0 - - ZStack { - cardFrontView( - cornerRadius: cornerRadius, - borderColor: borderColor, - borderWidth: borderWidth, - shadowColor: shadowColor, - shadowRadius: shadowRadius, - shadowYOffset: shadowYOffset - ) - .opacity(frontOpacity) - .rotation3DEffect(.degrees(frontAngle), axis: (x: 0, y: 1, z: 0), perspective: 0.7) - - cardBackView( - cornerRadius: cornerRadius, - borderColor: borderColor, - borderWidth: borderWidth, - shadowColor: shadowColor, - shadowRadius: shadowRadius, - shadowYOffset: shadowYOffset - ) - .opacity(backOpacity) - .rotation3DEffect(.degrees(backAngle), axis: (x: 0, y: 1, z: 0), perspective: 0.7) - } - .frame(width: cardSize.width, height: cardSize.height) - .rotationEffect(.degrees(tiltAngle)) - .hintWiggle(token: hintWiggleToken) - .scaleEffect(isSelected ? 1.03 : 1) - .onChange(of: card.isFaceUp) { _, newValue in - withAnimation(.easeInOut(duration: 0.32)) { - flipRotation = newValue ? 0 : 180 - } - } - .onAppear { - if flipOnAppear, card.isFaceUp, flipRotation != 0 { - withAnimation(.easeInOut(duration: 0.22).delay(flipDelay)) { - flipRotation = 0 - } - } - let targetTilt: Double - if isCardTiltEnabled { - if let existing = cardTilts[card.id] { - targetTilt = existing - } else { - let newTilt = Double.random(in: CardTilt.angleRange) - cardTilts[card.id] = newTilt - targetTilt = newTilt - } - } else { - targetTilt = 0 - } - animateTilt(to: targetTilt) - } - .onChange(of: cardTilts[card.id]) { _, newTilt in - guard isCardTiltEnabled, let newTilt else { return } - animateTilt(to: newTilt) - } - } - - private func animateTilt(to target: Double) { - withAnimation(.easeOut(duration: 0.2)) { - tiltAngle = target - } - } - - @ViewBuilder - private func cardFrontView( - cornerRadius: CGFloat, - borderColor: Color, - borderWidth: CGFloat, - shadowColor: Color, - shadowRadius: CGFloat, - shadowYOffset: CGFloat - ) -> some View { - switch cardStyle { - case .pixel: - PixelCardFrontView(card: card, cardSize: cardSize, isSelected: isSelected) - case .classic: - classicCardFront( - cornerRadius: cornerRadius, - borderColor: borderColor, - borderWidth: borderWidth, - shadowColor: shadowColor, - shadowRadius: shadowRadius, - shadowYOffset: shadowYOffset - ) - } - } - - @ViewBuilder - private func cardBackView( - cornerRadius: CGFloat, - borderColor: Color, - borderWidth: CGFloat, - shadowColor: Color, - shadowRadius: CGFloat, - shadowYOffset: CGFloat - ) -> some View { - switch cardStyle { - case .pixel: - PixelCardBackView(cardSize: cardSize, isSelected: isSelected) - case .classic: - classicCardBack( - cornerRadius: cornerRadius, - borderColor: borderColor, - borderWidth: borderWidth, - shadowColor: shadowColor, - shadowRadius: shadowRadius, - shadowYOffset: shadowYOffset - ) - } - } - - private func classicCardFront( - cornerRadius: CGFloat, - borderColor: Color, - borderWidth: CGFloat, - shadowColor: Color, - shadowRadius: CGFloat, - shadowYOffset: CGFloat - ) -> some View { - let parchment = Color(red: 0.98, green: 0.96, blue: 0.91) - let inkColor = card.suit.isRed ? Color(red: 0.72, green: 0.16, blue: 0.18) : Color(red: 0.12, green: 0.12, blue: 0.12) - let ornamentColor = Color(red: 0.66, green: 0.58, blue: 0.48) - let cornerMark = VStack(alignment: .leading, spacing: 2) { - Text(card.rank.label) - .font(.system(size: cardSize.width * 0.28, weight: .bold, design: .serif)) - Image(systemName: card.suit.symbolName) - .font(.system(size: cardSize.width * 0.2, weight: .semibold)) - } - - return ZStack { - cardBase( - cornerRadius: cornerRadius, - fill: parchment, - borderColor: borderColor, - borderWidth: borderWidth, - shadowColor: shadowColor, - shadowRadius: shadowRadius, - shadowYOffset: shadowYOffset - ) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill( - LinearGradient( - colors: [Color.white.opacity(0.55), Color.clear], - startPoint: UnitPoint.topLeading, - endPoint: UnitPoint.bottomTrailing - ) - ) - .blendMode(.softLight) - ) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius * 0.92, style: .continuous) - .strokeBorder(ornamentColor.opacity(0.6), lineWidth: 1) - .padding(cardSize.width * 0.06) - ) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius * 0.78, style: .continuous) - .strokeBorder( - ornamentColor.opacity(0.45), - style: StrokeStyle(lineWidth: 0.6, dash: [4, 3]) - ) - .padding(cardSize.width * 0.1) - ) - - cornerMark - .foregroundStyle(inkColor) - .padding(cardSize.width * 0.1) - .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.topLeading) - - cornerMark - .foregroundStyle(inkColor) - .rotationEffect(.degrees(180)) - .padding(cardSize.width * 0.1) - .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.bottomTrailing) - - Image(systemName: card.suit.symbolName) - .font(.system(size: cardSize.width * 0.52, weight: .regular)) - .foregroundStyle(inkColor.opacity(0.12)) - .rotationEffect(.degrees(8)) - .frame(width: cardSize.width, height: cardSize.height, alignment: Alignment.center) - } - } - - private func classicCardBack( - cornerRadius: CGFloat, - borderColor: Color, - borderWidth: CGFloat, - shadowColor: Color, - shadowRadius: CGFloat, - shadowYOffset: CGFloat - ) -> some View { - let lacquer = Color(red: 0.18, green: 0.26, blue: 0.52) - let trim = Color(red: 0.78, green: 0.85, blue: 0.95) - - return ZStack { - cardBase( - cornerRadius: cornerRadius, - fill: lacquer, - borderColor: borderColor, - borderWidth: borderWidth, - shadowColor: shadowColor, - shadowRadius: shadowRadius, - shadowYOffset: shadowYOffset - ) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius * 0.92, style: .continuous) - .strokeBorder(trim.opacity(0.55), lineWidth: 1) - .padding(cardSize.width * 0.08) - ) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius * 0.8, style: .continuous) - .strokeBorder(trim.opacity(0.35), style: StrokeStyle(lineWidth: 0.6, dash: [5, 3])) - .padding(cardSize.width * 0.12) - ) - - CardBackPattern() - .padding(cardSize.width * 0.18) - } - } - - private func cardBase( - cornerRadius: CGFloat, - fill: Color, - borderColor: Color, - borderWidth: CGFloat, - shadowColor: Color, - shadowRadius: CGFloat, - shadowYOffset: CGFloat - ) -> some View { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(fill) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .stroke(borderColor, lineWidth: borderWidth) - ) - .shadow(color: shadowColor, radius: shadowRadius, x: 0, y: shadowYOffset) - } -} - -struct CardBackPattern: View { - var body: some View { - GeometryReader { geometry in - let size = geometry.size - ZStack { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color.white.opacity(0.18), lineWidth: 1) - Path { path in - let step: CGFloat = 10 - var x: CGFloat = 0 - while x < size.width { - path.move(to: CGPoint(x: x, y: 0)) - path.addLine(to: CGPoint(x: x, y: size.height)) - x += step - } - } - .stroke(Color.white.opacity(0.12), lineWidth: 1) - - Path { path in - let step: CGFloat = 10 - var y: CGFloat = 0 - while y < size.height { - path.move(to: CGPoint(x: 0, y: y)) - path.addLine(to: CGPoint(x: size.width, y: y)) - y += step - } - } - .stroke(Color.white.opacity(0.08), lineWidth: 1) - } - } - } -} - -struct CardBackView: View { - let cardSize: CGSize - @Environment(\.cardStyle) private var cardStyle - - var body: some View { - switch cardStyle { - case .pixel: - PixelStandaloneCardBackView(cardSize: cardSize) - case .classic: - classicCardBack - } - } - - private var classicCardBack: some View { - let cornerRadius = cardSize.width * 0.12 - let lacquer = Color(red: 0.18, green: 0.26, blue: 0.52) - let trim = Color(red: 0.78, green: 0.85, blue: 0.95) - - return ZStack { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .fill(lacquer) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .stroke(trim.opacity(0.5), lineWidth: 1) - .padding(cardSize.width * 0.06) - ) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .stroke(trim.opacity(0.25), style: StrokeStyle(lineWidth: 0.6, dash: [5, 3])) - .padding(cardSize.width * 0.1) - ) - .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) - - CardBackPattern() - .padding(cardSize.width * 0.18) - } - .frame(width: cardSize.width, height: cardSize.height) + let natural = lastYOffset + (lastCard.isFaceUp ? faceUpOffset : faceDownOffset) + let maxTopOffset = maxPileHeight - cardSize.height + return maxTopOffset > 0 ? min(natural, maxTopOffset) : natural } } @@ -1014,79 +690,20 @@ struct TableBackground: View { var body: some View { let baseColor = (TableBackgroundColor(rawValue: tableBackgroundColorRawValue) ?? TableBackgroundColor.defaultValue).color - ZStack { - baseColor - + Group { if feltEffectEnabled { - // Felt fiber texture - FeltTextureOverlay() - - // Vignette: darken edges for a real table look - RadialGradient( - gradient: Gradient(colors: [ - Color.clear, - Color.black.opacity(0.35) - ]), - center: .center, - startRadius: 100, - endRadius: 900 - ) + GeometryReader { proxy in + baseColor + .colorEffect(ShaderLibrary.feltTexture(.float2(proxy.size))) + } + } else { + baseColor } } .ignoresSafeArea() } } -/// Procedural felt fiber noise drawn via Canvas. -private struct FeltTextureOverlay: View { - var body: some View { - Canvas { context, size in - // Deterministic seed-based RNG for consistent pattern - var rng = FeltRNG(seed: 42) - let count = Int(size.width * size.height * 0.06) - for _ in 0.. 0.5 - let color = isLight - ? Color.white.opacity(opacity) - : Color.black.opacity(opacity) - context.stroke(path, with: .color(color), lineWidth: 0.5) - } - } - } -} - -/// Simple splitmix-style RNG for deterministic felt pattern. -private struct FeltRNG { - private var state: UInt64 - - init(seed: UInt64) { - state = seed - } - - /// Returns a value in 0..<1 - mutating func next() -> Double { - state &+= 0x9e3779b97f4a7c15 - var z = state - z = (z ^ (z >> 30)) &* 0xbf58476d1ce4e5b9 - z = (z ^ (z >> 27)) &* 0x94d049bb133111eb - z = z ^ (z >> 31) - return Double(z &>> 11) / Double(1 << 53) - } -} - struct WinOverlay: View { let score: Int let onNewGame: () -> Void diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index fd7c181..b3ddd4e 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -12,11 +12,17 @@ struct DropTargetFrameKey: PreferenceKey { } } +// Single-frame keys must ignore the default: on macOS, sibling subtrees that +// never set the key still run reduce with .zero, and a last-wins reducer lets +// that erase the real frame (the draw animation then never runs). struct StockFrameKey: PreferenceKey { static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() + let next = nextValue() + if next != .zero { + value = next + } } } @@ -24,7 +30,10 @@ struct WasteFrameKey: PreferenceKey { static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() + let next = nextValue() + if next != .zero { + value = next + } } } @@ -36,14 +45,6 @@ struct CardFrameKey: PreferenceKey { } } -struct BoardContentSizeKey: PreferenceKey { - static var defaultValue: CGSize = .zero - - static func reduce(value: inout CGSize, nextValue: () -> CGSize) { - value = nextValue() - } -} - struct CardFramePreference: ViewModifier { let cardID: UUID let xOffset: CGFloat @@ -112,7 +113,6 @@ struct ContentView: View { @State private var isUndoAnimating = false @State private var hiddenCardIDs: Set = [] @State private var wasteFanProgress: [UUID: Double] = [:] - @State private var boardContentSize: CGSize = .zero @State private var boardViewportSize: CGSize = .zero @State private var previousWasteCount: Int = 0 @State private var previousStockCount: Int = 0 @@ -134,7 +134,7 @@ struct ContentView: View { @AppStorage(SettingsKey.gameVariant) private var gameVariantRawValue = GameVariant.klondike.rawValue @AppStorage(SettingsKey.drawMode) private var drawModeRawValue = DrawMode.three.rawValue @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = true - @AppStorage(SettingsKey.cardStyle) private var cardStyleRawValue = CardStyle.classic.rawValue + @AppStorage(SettingsKey.cardStyle) private var cardStyleRawValue = CardStyle.defaultValue.rawValue private var gameVariant: GameVariant { GameVariant(rawValue: gameVariantRawValue) ?? .klondike @@ -165,7 +165,7 @@ struct ContentView: View { } private var currentCardStyle: CardStyle { - CardStyle(rawValue: cardStyleRawValue) ?? .classic + CardStyle(rawValue: cardStyleRawValue) ?? .defaultValue } var body: some View { @@ -433,10 +433,13 @@ struct ContentView: View { let metrics = Layout.metrics(for: geometry.size, tableauColumnCount: boardColumnCount) #endif let cardSize = metrics.cardSize - let boardScaleFactor = boardScaleFactor(for: geometry.size) - let effectiveCardSize = CGSize(width: cardSize.width * boardScaleFactor, height: cardSize.height * boardScaleFactor) let boardContentWidth = (cardSize.width * CGFloat(boardColumnCount)) + (metrics.columnSpacing * CGFloat(max(0, boardColumnCount - 1))) + let boardScaleFactor = boardScaleFactor( + availableWidth: geometry.size.width, + requiredWidth: boardContentWidth + (metrics.horizontalPadding * 2) + ) + let effectiveCardSize = CGSize(width: cardSize.width * boardScaleFactor, height: cardSize.height * boardScaleFactor) let isBoardReady = hasLoadedGame && !isHydratingGame let hintedTarget: DropTarget? = { guard let destination = viewModel.hintedDestination else { return nil } @@ -487,6 +490,7 @@ struct ContentView: View { columnSpacing: metrics.columnSpacing, faceDownOffset: metrics.tableauFaceDownOffset, faceUpOffset: metrics.tableauFaceUpOffset, + maxPileHeight: metrics.tableauMaxHeight, activeTarget: activeTarget, hintedTarget: hintedTarget, hintHighlightOpacity: hintHighlightOpacity, @@ -514,11 +518,6 @@ struct ContentView: View { .padding(.vertical, metrics.verticalPadding) boardLayout - .background( - GeometryReader { proxy in - Color.clear.preference(key: BoardContentSizeKey.self, value: proxy.size) - } - ) .scaleEffect(boardScaleFactor, anchor: .top) Button("Cancel Drag") { @@ -550,9 +549,6 @@ struct ContentView: View { cardFrames = frames } } - .onPreferenceChange(BoardContentSizeKey.self) { size in - boardContentSize = size - } .onAppear { boardViewportSize = geometry.size refreshLoadedWinPresentationIfNeeded() @@ -587,9 +583,20 @@ struct ContentView: View { let newCards = addedCount > 0 ? Array(viewModel.state.waste.suffix(addedCount)) : [] syncFanProgress(with: viewModel.state.waste, excluding: Set(newCards.map(\.id))) if addedCount > 0, stockCount < previousStockCount { - prepareFan(for: newCards) - let travelDelay = startDrawAnimation(for: newCards, cardSize: effectiveCardSize) - animateFan(for: newCards, delay: travelDelay) + // The overlay cards fan themselves while flipping; the real + // cards wait fully fanned underneath. + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + for card in newCards { + wasteFanProgress[card.id] = 1 + } + } + startDrawAnimation( + for: newCards, + cardSize: effectiveCardSize, + fanSpacing: metrics.wasteFanSpacing * boardScaleFactor + ) } previousWasteCount = newValue previousStockCount = stockCount @@ -601,7 +608,9 @@ struct ContentView: View { ZStack { DrawOverlayView( cards: drawAnimationCards, - cardSize: effectiveCardSize + cardSize: effectiveCardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts ) .zIndex(50) UndoOverlayView( @@ -1012,45 +1021,26 @@ struct ContentView: View { } } - private func prepareFan(for newCards: [Card]) { - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - for card in newCards { - wasteFanProgress[card.id] = 0 - } - } - } - - private func animateFan(for newCards: [Card], delay: Double) { - for (index, card) in newCards.enumerated() { - let stagger = 0.04 * Double(index) - withAnimation(.spring(response: 0.22, dampingFraction: 0.82).delay(delay + stagger)) { - wasteFanProgress[card.id] = 1 - } - } - } - - private func startDrawAnimation(for newCards: [Card], cardSize: CGSize) -> Double { + private func startDrawAnimation(for newCards: [Card], cardSize: CGSize, fanSpacing: CGFloat) { guard let plan = DrawAnimationCoordinator.makeDrawPlan( newCards: newCards, cardSize: cardSize, stockFrame: stockFrame, - wasteFrame: wasteFrame + wasteFrame: wasteFrame, + fanSpacing: fanSpacing ) else { - return 0 + return } drawAnimationCards = plan.cards drawingCardIDs = plan.cardIDs drawAnimationToken = plan.token - DispatchQueue.main.asyncAfter(deadline: .now() + plan.travelDuration + plan.totalDelay) { + DispatchQueue.main.asyncAfter(deadline: .now() + plan.travelDuration + plan.settleDuration) { guard drawAnimationToken == plan.token else { return } drawAnimationCards = [] drawingCardIDs = [] } - return plan.travelDuration + plan.totalDelay } private func destination(for target: DropTarget) -> Destination { @@ -1443,11 +1433,11 @@ struct ContentView: View { } } - private func boardScaleFactor(for availableSize: CGSize) -> CGFloat { - guard boardContentSize.width > 0, boardContentSize.height > 0 else { return 1 } - let widthScale = availableSize.width / boardContentSize.width - let heightScale = availableSize.height / boardContentSize.height - return min(1, widthScale, heightScale) + /// Width overflow is computed analytically from the same inputs the board + /// lays out with; vertical overflow is prevented by per-pile compression. + private func boardScaleFactor(availableWidth: CGFloat, requiredWidth: CGFloat) -> CGFloat { + guard requiredWidth > 0 else { return 1 } + return min(1, availableWidth / requiredWidth) } private func restoreScreenshotFixtureIfRequested() -> Bool { diff --git a/ComputerSolitaire/Views/Shared/FeltTexture.metal b/ComputerSolitaire/Views/Shared/FeltTexture.metal new file mode 100644 index 0000000..6d95071 --- /dev/null +++ b/ComputerSolitaire/Views/Shared/FeltTexture.metal @@ -0,0 +1,44 @@ +#include +#include +using namespace metal; + +/// Integer hash (lowbias32-style): stays uncorrelated at any coordinate, +/// unlike fract/sin float hashes which streak at large positions. +static float hash21(float2 p) { + uint2 q = uint2(int2(floor(p))); + uint h = q.x * 1597334673u ^ q.y * 3812015801u; + h = (h ^ (h >> 16)) * 0x7feb352du; + h = (h ^ (h >> 15)) * 0x846ca68bu; + h = h ^ (h >> 16); + return float(h) * (1.0 / 4294967296.0); +} + +/// Felt table texture: modulates the base color's luminance with fine grain, +/// a top sheen, and a size-relative vignette. +[[ stitchable ]] half4 feltTexture(float2 position, half4 color, float2 size) { + if (color.a <= 0.0h) { + return color; + } + + // Fine grain on a one-point grid — coarse enough to read as fabric + // tooth at viewing distance, fine enough to stay uniform. + float grain = hash21(position) - 0.5; + + // Taper grain on light base colors, where the same multiplicative + // amplitude reads much grittier than on dark ones. + float luma = dot(float3(color.rgb), float3(0.299, 0.587, 0.114)); + float grainAmp = 0.15 * mix(1.0, 0.5, smoothstep(0.35, 0.85, luma)); + + float lum = 1.0 + grain * grainAmp; + + // Sheen: light falling from the top of the table. + float sheen = 1.0 - smoothstep(0.0, size.y * 0.45, position.y); + lum += sheen * 0.06; + + // Vignette, relative to the view's diagonal. + float2 centered = (position - size * 0.5) / (0.5 * length(size)); + float vignette = smoothstep(0.35, 1.05, length(centered)); + lum *= 1.0 - vignette * 0.28; + + return half4(clamp(color.rgb * half(lum), 0.0h, 1.0h), color.a); +} diff --git a/ComputerSolitaireTests/Shared/CardStyleTests.swift b/ComputerSolitaireTests/Shared/CardStyleTests.swift new file mode 100644 index 0000000..f0bf50f --- /dev/null +++ b/ComputerSolitaireTests/Shared/CardStyleTests.swift @@ -0,0 +1,27 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class CardStyleTests: XCTestCase { + func testDefaultStyleIsClassic() { + XCTAssertEqual(CardStyle.defaultValue, .classic) + XCTAssertEqual(CardStyle.defaultValue.rawValue, "classic") + } + + func testOnlyCurrentRawValuesResolveToStyles() { + XCTAssertEqual(CardStyle(rawValue: "classic"), .classic) + XCTAssertEqual(CardStyle(rawValue: "simple"), .simple) + XCTAssertEqual(CardStyle(rawValue: "pixel"), .pixel) + XCTAssertNil(CardStyle(rawValue: "legacy")) + XCTAssertNil(CardStyle(rawValue: "default")) + } + + func testStyleMetadataUsesProductNames() { + XCTAssertEqual(CardStyle.classic.title, "Classic") + XCTAssertEqual(CardStyle.classic.subtitle, "Parchment") + XCTAssertEqual(CardStyle.simple.title, "Simple") + XCTAssertEqual(CardStyle.simple.subtitle, "Clean") + XCTAssertEqual(CardStyle.pixel.title, "Pixel") + XCTAssertEqual(CardStyle.pixel.subtitle, "8-bit Retro") + } +}