Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions ComputerSolitaire/Animation/BoardWipeCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import CoreGraphics
import Foundation

struct BoardWipeCard: Identifiable {
let id: UUID
let card: Card
let size: CGSize
let start: CGPoint
/// How far behind the palm's leading edge this card rides once caught;
/// small offsets fan the clump instead of stacking it into one pixel.
let rideOffset: CGFloat
/// Vertical push the card picks up as it's carried.
let wobble: CGFloat
/// Tilt the card picks up as it's carried.
let rotationDegrees: Double
}

/// Builds the clear-the-table sweep that precedes a fresh deal: a palm
/// lands at the board's left edge and wipes across, accelerating as it
/// goes. Cards are caught where they rest and then ride the palm's front,
/// piling into a clump that grows as the stroke crosses and carries the
/// whole board off the right edge. The stock never wipes: it is the deck
/// the next deal comes from (and it publishes no card frames, so it is
/// excluded by construction).
enum BoardWipeCoordinator {
struct Plan {
let cards: [BoardWipeCard]
let token: UUID
/// How far the palm's front travels over the stroke; far enough to
/// shove the whole clump past the right edge.
let sweepSpan: CGFloat
}

/// One stroke, plant to follow-through. The overlay's animation and the
/// teardown completion both read this single constant.
static let strokeDuration: Double = 0.45

/// The palm's position along the stroke: quadratic in time, so the
/// stroke plants slowly and whips through the far side. Drive it with
/// linear progress; the acceleration lives here.
static func frontPosition(progress: CGFloat, sweepSpan: CGFloat) -> CGFloat {
sweepSpan * progress * progress
}

/// A card's rightward displacement at `progress`: zero until the palm's
/// front reaches its resting spot, then pinned to the front (less its
/// ride offset) — which is what piles caught cards into one traveling
/// clump instead of letting each glide off alone.
static func sweptDisplacement(
progress: CGFloat,
startX: CGFloat,
rideOffset: CGFloat,
sweepSpan: CGFloat
) -> CGFloat {
max(0, frontPosition(progress: progress, sweepSpan: sweepSpan) - rideOffset - startX)
}

static func makeWipePlan(
cards: [Card],
cardFrames: [UUID: CGRect],
boardSize: CGSize
) -> Plan? {
guard boardSize != .zero else { return nil }
let framed = cards
.compactMap { card -> (card: Card, frame: CGRect)? in
guard let frame = cardFrames[card.id] else { return nil }
return (card, frame)
}
.sorted {
// Draw order IS stacking order, and it must reproduce the
// board's: overlapping rows (TriPeaks, Pyramid) and fanned
// piles both render lower-on-screen cards on top, so sort
// y-major — an x-major order flips who's on top along every
// overlapped edge the instant the overlay stands in.
($0.frame.minY, $0.frame.minX) < ($1.frame.minY, $1.frame.minX)
}
guard !framed.isEmpty else { return nil }

let maxCardWidth = framed.map(\.frame.width).max() ?? 0
let items = framed.enumerated().map { index, item -> BoardWipeCard in
// Deterministic per-card jostle, so the clump reads as shoved
// cards rather than a block — without randomness that would
// make tests and resumes flaky.
BoardWipeCard(
id: item.card.id,
card: item.card,
size: item.frame.size,
start: CGPoint(x: item.frame.midX, y: item.frame.midY),
rideOffset: CGFloat(index % 6) * item.frame.width * 0.12,
wobble: CGFloat((index % 5) - 2) * 10,
rotationDegrees: Double((index % 9) - 4) * 3
)
}

return Plan(
cards: items,
token: UUID(),
// Far enough that the front clears the board plus the deepest
// ride offset plus a full card — everything ends off-screen.
sweepSpan: boardSize.width + maxCardWidth * 2.5
)
}
}
89 changes: 87 additions & 2 deletions ComputerSolitaire/Animation/DealAnimationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ enum DealAnimationCoordinator {
/// left-to-right sweep, reading as a deal rather than simultaneous pops.
static let staggerInterval: Double = 0.05

/// One pace for every deal flight — the stock deal and the fresh-board
/// deal must never drift apart.
static let travelDuration: Double = 0.32
static let settleDuration: Double = 0.12

/// `dealtCards` in pile order (leftmost pile's card first). Cards without
/// a published frame — a card banked the instant it landed — are skipped;
/// they surface through the banking animation instead.
Expand Down Expand Up @@ -59,8 +64,88 @@ enum DealAnimationCoordinator {
cards: items,
cardIDs: Set(items.map(\.id)),
token: UUID(),
travelDuration: 0.32,
settleDuration: 0.12,
travelDuration: travelDuration,
settleDuration: settleDuration,
maxDelay: items.last?.delay ?? 0
)
}

/// The whole fresh deal's takeoff window: per-card stagger shrinks as the
/// deal grows, so Klondike's 28 cards and Spider's 54 read as one sweep
/// of the same length rather than a parade that scales with card count.
static let newGameTakeoffWindow: Double = 1.0

/// A fresh board's cards in the order a dealer lays them down: the
/// reserve packet is set down first (only its exposed top card flies —
/// the buried cards render as the set-down stack), the tableau deals in
/// left-to-right passes, the pyramid and peaks build from the top row
/// down, and starter cards (Canfield's foundation base, Golf's and
/// TriPeaks' waste card) turn over last. Zones a variant doesn't deal to
/// are empty, so the one ordering serves every variant.
static func newGameDealSequence(in state: GameState) -> [Card] {
var sequence: [Card] = []
if let reserveTop = state.reserve.last {
sequence.append(reserveTop)
}
let tallestPileCount = state.tableau.map(\.count).max() ?? 0
for row in 0..<tallestPileCount {
for pile in state.tableau where row < pile.count {
sequence.append(pile[row])
}
}
sequence.append(contentsOf: state.pyramid.compactMap { $0 })
sequence.append(contentsOf: state.triPeaks.compactMap { $0 })
sequence.append(contentsOf: state.foundations.flatMap { $0 })
sequence.append(contentsOf: state.waste)
return sequence
}

/// Builds the deal-in flight for a fresh board (new game, redeal, Golf's
/// next hole): every dealt card flies from the stock to its slot,
/// face-down cards traveling face-down and face-up cards flipping in the
/// air like the draw flight's. Stockless variants (FreeCell, Yukon) deal
/// from an invisible deck just above the board's top edge instead.
static func makeNewGameDealPlan(
dealtCards: [Card],
cardFrames: [UUID: CGRect],
stockFrame: CGRect,
boardSize: CGSize
) -> Plan? {
let flying = dealtCards.compactMap { card -> (card: Card, frame: CGRect)? in
guard let frame = cardFrames[card.id] else { return nil }
return (card, frame)
}
guard !flying.isEmpty else { return nil }

let start: CGPoint
if stockFrame != .zero {
start = CGPoint(x: stockFrame.midX, y: stockFrame.midY)
} else {
guard boardSize != .zero else { return nil }
let cardHeight = flying[0].frame.height
start = CGPoint(x: boardSize.width * 0.5, y: -cardHeight)
}

let stagger = min(
staggerInterval,
newGameTakeoffWindow / Double(max(1, flying.count - 1))
)
let items = flying.enumerated().map { index, item -> DrawAnimationCard in
DrawAnimationCard(
id: item.card.id,
card: item.card,
start: start,
end: CGPoint(x: item.frame.midX, y: item.frame.midY),
delay: stagger * Double(index)
)
}

return Plan(
cards: items,
cardIDs: Set(items.map(\.id)),
token: UUID(),
travelDuration: travelDuration,
settleDuration: settleDuration,
maxDelay: items.last?.delay ?? 0
)
}
Expand Down
4 changes: 4 additions & 0 deletions ComputerSolitaire/Animation/MotionPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ struct MotionPolicy: Equatable {
isInstant ? nil : .spring(response: response * scale, dampingFraction: dampingFraction)
}

func linear(_ baseDuration: Double) -> Animation? {
isInstant ? nil : .linear(duration: baseDuration * scale)
}

func easeOut(_ baseDuration: Double) -> Animation? {
isInstant ? nil : .easeOut(duration: baseDuration * scale)
}
Expand Down
12 changes: 12 additions & 0 deletions ComputerSolitaire/Game/Shared/GameSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ final class SolitaireViewModel {
func publishTableauDealEvent(dealtCardIDs: [UUID]) {
latestTableauDealEvent = TableauDealEvent(id: UUID(), dealtCardIDs: dealtCardIDs)
}

/// The most recent fresh deal (new game, redeal, Golf's next hole),
/// published for the board's deal-in flight. Like `TableauDealEvent`, an
/// explicit event rather than an inferred state diff, so restores and
/// game switches can never replay a deal that already happened.
struct BoardDealEvent: Equatable {
let id: UUID
}
private(set) var latestBoardDealEvent: BoardDealEvent?
private(set) var movesCount: Int = 0
private(set) var score: Int = 0
private(set) var gameStartedAt: Date = .now
Expand Down Expand Up @@ -307,6 +316,7 @@ final class SolitaireViewModel {
isDragging = false
pendingAutoMove = nil
latestTableauDealEvent = nil
latestBoardDealEvent = BoardDealEvent(id: UUID())
movesCount = 0
score = 0
gameStartedAt = dateProvider.now
Expand All @@ -332,6 +342,7 @@ final class SolitaireViewModel {
selection = nil
isDragging = false
pendingAutoMove = nil
latestBoardDealEvent = BoardDealEvent(id: UUID())
movesCount = 0
score = 0
gameStartedAt = dateProvider.now
Expand Down Expand Up @@ -398,6 +409,7 @@ final class SolitaireViewModel {
guard let sanitizedPayload = payload.sanitizedForRestore(at: now) else { return false }
clearHint()
latestTableauDealEvent = nil
latestBoardDealEvent = nil
let offlineDurationSinceSave = max(0, now.timeIntervalSince(sanitizedPayload.savedAt))
state = sanitizedPayload.state
movesCount = sanitizedPayload.movesCount
Expand Down
9 changes: 9 additions & 0 deletions ComputerSolitaire/Views/Cards/CardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,15 @@ struct CardView: View {
self.currentTilt = cardTilts.wrappedValue[card.id]
let startFaceDown = flipOnAppear && card.isFaceUp
_flipRotation = State(initialValue: startFaceDown ? 180 : (card.isFaceUp ? 0 : 180))
// A card with a stored tilt must render leaning from its very first
// frame: `onAppear` lands after the first render, so seeding there
// leaves one straight frame — invisible for cards that mount hidden
// or in flight, but a visible whole-board twitch when an overlay
// (the wipe) replaces resting cards with copies in place. Cards
// without a stored tilt still get one in `onAppear`.
_tiltAngle = State(
initialValue: isCardTiltEnabled ? (cardTilts.wrappedValue[card.id] ?? 0) : 0
)
}

var body: some View {
Expand Down
Loading