diff --git a/ComputerSolitaire/Animation/WinCascadeCoordinator.swift b/ComputerSolitaire/Animation/WinCascadeCoordinator.swift index 69f4474..abe6d08 100644 --- a/ComputerSolitaire/Animation/WinCascadeCoordinator.swift +++ b/ComputerSolitaire/Animation/WinCascadeCoordinator.swift @@ -31,6 +31,11 @@ enum WinCascadeCoordinator { private static let angularVelocityDampingOnBounce: Double = 0.82 private static let maxActiveLifetime: TimeInterval = 6.5 private static let baseLaunchDelay: TimeInterval = 0.03 + /// Substep the accumulator divides real frame time into. + private static let substepDuration: TimeInterval = 1.0 / 60.0 + /// Cap on simulated time per frame: a long stall (app backgrounded + /// mid-cascade) jumps ahead sanely instead of fast-forwarding the show. + private static let maxAdvancePerFrame: TimeInterval = 8.0 / 60.0 static func makeInitialStates( foundations: [[Card]], @@ -69,22 +74,40 @@ enum WinCascadeCoordinator { } } - static func step( + /// Advances the simulation by real frame time, integrating in substeps + /// so simulated time matches wall time at any display refresh rate. + static func advance( + states: inout [WinCascadeCardState], + by deltaTime: TimeInterval, + boardBounds: CGRect + ) { + var remaining = min(deltaTime, maxAdvancePerFrame) + while remaining > 0 { + let substep = min(remaining, substepDuration) + step(states: &states, deltaTime: substep, boardBounds: boardBounds) + remaining -= substep + } + } + + /// Integrates one substep; `deltaTime` must not exceed `substepDuration`, + /// which `advance` and the settled-state replay guarantee. + private static func step( states: inout [WinCascadeCardState], deltaTime: TimeInterval, boardBounds: CGRect ) { - guard !states.isEmpty else { return } + guard deltaTime > 0, !states.isEmpty else { return } guard boardBounds.width > 0, boardBounds.height > 0 else { return } - let dt = CGFloat(max(1.0 / 120.0, min(1.0 / 30.0, deltaTime))) + let dt = CGFloat(deltaTime) for index in states.indices { - states[index].elapsed += TimeInterval(dt) - if states[index].elapsed < states[index].activationDelay { + // Skipped before the elapsed bump so settled state never changes. + if states[index].isSettled { continue } - if states[index].isSettled { + states[index].elapsed += TimeInterval(dt) + if states[index].elapsed < states[index].activationDelay { continue } diff --git a/ComputerSolitaire/Animation/WinCelebrationController.swift b/ComputerSolitaire/Animation/WinCelebrationController.swift index db49a0e..12d1de4 100644 --- a/ComputerSolitaire/Animation/WinCelebrationController.swift +++ b/ComputerSolitaire/Animation/WinCelebrationController.swift @@ -11,16 +11,59 @@ final class WinCelebrationController { case completed } - private(set) var cards: [WinCascadeCardState] = [] + /// Launch-time snapshot the overlay builds its Canvas symbols from. + /// Replacing it is what triggers a re-render; the live `cards` below + /// are invisible to observation. + private(set) var launchStates: [WinCascadeCardState] = [] private(set) var hiddenFoundationCardIDs: Set = [] private(set) var phase: Phase = .idle - private var cascadeTask: Task? + /// Live simulation state. Unobserved: the Canvas renderer reads it + /// untracked, and its per-frame mutation must not schedule updates on + /// top of the TimelineView's own. + @ObservationIgnored private(set) var cards: [WinCascadeCardState] = [] + @ObservationIgnored private var lastTickDate: Date? + @ObservationIgnored private var isCompletionScheduled = false + /// Keeps a deferred completion from landing on a later cascade. + @ObservationIgnored private var cascadeGeneration = 0 var isAnimating: Bool { phase == .animating } + /// Advances the physics by real elapsed time, once per display frame + /// from the Canvas renderer, so a slow frame skips ahead instead of + /// stretching the cascade into slow motion. + func tick(at date: Date, boardBounds: CGRect) { + guard phase == .animating else { return } + guard let previousTickDate = lastTickDate else { + // First frame draws the launch positions as-is. + lastTickDate = date + return + } + let deltaTime = date.timeIntervalSince(previousTickDate) + lastTickDate = date + guard deltaTime > 0 else { return } + + WinCascadeCoordinator.advance( + states: &cards, + by: deltaTime, + boardBounds: boardBounds + ) + + if !cards.isEmpty, !isCompletionScheduled, cards.allSatisfy(\.isSettled) { + // Defer the observable phase flip out of the render pass. + isCompletionScheduled = true + let generation = cascadeGeneration + Task { @MainActor [weak self] in + guard let self, + self.cascadeGeneration == generation, + self.phase == .animating else { return } + self.phase = .completed + } + } + } + /// `launchPiles` are the piles the cascade erupts from, with `launchTargets` /// naming each pile's on-board drop target (aligned by index): the four /// foundations for the build-up variants, the discard for Pyramid. @@ -41,10 +84,12 @@ final class WinCelebrationController { } func reset(to phase: Phase = .idle) { - cascadeTask?.cancel() - cascadeTask = nil + cascadeGeneration += 1 cards = [] + launchStates = [] hiddenFoundationCardIDs = [] + lastTickDate = nil + isCompletionScheduled = false self.phase = phase } @@ -55,8 +100,9 @@ final class WinCelebrationController { dropFrames: [DropTarget: DropTargetGeometry], boardViewportSize: CGSize ) { - cascadeTask?.cancel() - cascadeTask = nil + cascadeGeneration += 1 + lastTickDate = nil + isCompletionScheduled = false if isWin { let completedCards = completedStatesForLoadedWin( launchPiles: launchPiles, @@ -65,22 +111,19 @@ final class WinCelebrationController { boardViewportSize: boardViewportSize ) cards = completedCards + launchStates = completedCards hiddenFoundationCardIDs = completedCards.isEmpty ? [] : Self.launchCardIDs(from: launchPiles) phase = .completed } else { cards = [] + launchStates = [] hiddenFoundationCardIDs = [] phase = .idle } } - func cancelTask() { - cascadeTask?.cancel() - cascadeTask = nil - } - private func begin( launchPiles: [[Card]], launchTargets: [DropTarget], @@ -111,37 +154,12 @@ final class WinCelebrationController { return } - cascadeTask?.cancel() + cascadeGeneration += 1 cards = initialStates + launchStates = initialStates + lastTickDate = nil + isCompletionScheduled = false phase = .animating - - cascadeTask = Task { @MainActor in - let tickNanos: UInt64 = 16_666_667 - - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: tickNanos) - guard !Task.isCancelled else { return } - guard phase == .animating else { return } - - let bounds = CGRect(origin: .zero, size: boardViewportSize) - WinCascadeCoordinator.step( - states: &cards, - deltaTime: 1.0 / 60.0, - boardBounds: bounds - ) - - if !cards.isEmpty && cards.allSatisfy(\.isSettled) { - finish() - return - } - } - } - } - - private func finish() { - cascadeTask?.cancel() - cascadeTask = nil - phase = .completed } private static func launchCardIDs(from launchPiles: [[Card]]) -> Set { diff --git a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift index 584913c..ac4f43b 100644 --- a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift @@ -138,31 +138,74 @@ struct UndoOverlayView: View { } } +/// The falling-cards celebration as a single Canvas: a TimelineView supplies +/// the frame clock, and each card face resolves once as a symbol stamped +/// with a per-frame transform. struct WinCascadeOverlayView: View { - /// The cascade task mutates `cards` every frame while cards fly; reading - /// it here — not in ContentView — keeps the per-tick re-render confined - /// to this overlay. let winCelebration: WinCelebrationController var body: some View { - ForEach(winCelebration.cards) { item in - let isVisible = item.elapsed >= item.activationDelay - CardView( - card: item.card, - isSelected: false, - cardSize: item.size, - isCardTiltEnabled: false, - cardTilts: .constant([:]), - isAccessibilityElement: false - ) - .rotationEffect(.degrees(item.rotationDegrees)) - .position(item.position) - .opacity(isVisible ? 1 : 0) - .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2) + Group { + switch winCelebration.phase { + case .idle: + EmptyView() + case .animating: + TimelineView(.animation) { timeline in + cascadeCanvas(tickDate: timeline.date) + } + case .completed: + cascadeCanvas(tickDate: nil) + } } .allowsHitTesting(false) .accessibilityHidden(true) } + + /// A tickDate advances the simulation before drawing; nil draws the + /// settled states as-is. The canvas size is the live board viewport, so + /// the physics bounds track window resizes. + private func cascadeCanvas(tickDate: Date?) -> some View { + Canvas { context, size in + if let tickDate { + winCelebration.tick( + at: tickDate, + boardBounds: CGRect(origin: .zero, size: size) + ) + } + + var resolved: [(state: WinCascadeCardState, symbol: GraphicsContext.ResolvedSymbol)] = [] + resolved.reserveCapacity(winCelebration.cards.count) + for item in winCelebration.cards where item.elapsed >= item.activationDelay { + if let symbol = context.resolveSymbol(id: item.id) { + resolved.append((item, symbol)) + } + } + guard !resolved.isEmpty else { return } + + for (state, symbol) in resolved { + var cardContext = context + cardContext.translateBy(x: state.position.x, y: state.position.y) + cardContext.rotate(by: .degrees(state.rotationDegrees)) + cardContext.draw(symbol, at: .zero) + } + } symbols: { + // No added shadow: flying cards must match resting ones. The + // symmetric padding keeps the artwork's own shadow inside the + // raster without shifting the card's center. + ForEach(winCelebration.launchStates) { item in + CardView( + card: item.card, + isSelected: false, + cardSize: item.size, + isCardTiltEnabled: false, + cardTilts: .constant([:]), + isAccessibilityElement: false + ) + .padding(10) + .tag(item.id) + } + } + } } private struct DrawOverlayCardView: View { diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index b36e599..0ccb088 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -582,7 +582,6 @@ struct ContentView: View { initializeGameIfNeeded() } .onDisappear { - winCelebration.cancelTask() persistGameNow() } #if os(macOS)