From 81c94546985a2e349c19e1304848f542cb93cd17 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 11 Aug 2026 22:01:53 -0700 Subject: [PATCH 1/3] rework win cascade for display-clock timing and canvas rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the cascade was driven by a task.sleep loop stepping a fixed 1/60s per tick, so any dropped frame stretched the animation into slow motion, and each of the 52 cards rendered as a live swiftui view with its own per-frame shadow blur — the dominant gpu cost that caused the drops in the first place. the physics now advances by real elapsed time off a timelineview frame clock (native 120 hz on promotion), and the overlay renders as a single canvas that resolves each card face once as a symbol and stamps it with a per-frame transform. the extra cascade-only drop shadow is removed so flying cards match resting cards exactly; settled cards skip simulation entirely, and the canvas size feeds the physics bounds so mid-cascade resizes track. --- .../Animation/WinCascadeCoordinator.swift | 8 +- .../Animation/WinCelebrationController.swift | 107 +++++++++++------- .../Views/Shared/BoardOverlayViews.swift | 86 +++++++++++--- .../Views/Shared/ContentView.swift | 1 - 4 files changed, 141 insertions(+), 61 deletions(-) diff --git a/ComputerSolitaire/Animation/WinCascadeCoordinator.swift b/ComputerSolitaire/Animation/WinCascadeCoordinator.swift index 69f4474..93f9ff8 100644 --- a/ComputerSolitaire/Animation/WinCascadeCoordinator.swift +++ b/ComputerSolitaire/Animation/WinCascadeCoordinator.swift @@ -80,11 +80,13 @@ enum WinCascadeCoordinator { let dt = CGFloat(max(1.0 / 120.0, min(1.0 / 30.0, deltaTime))) for index in states.indices { - states[index].elapsed += TimeInterval(dt) - if states[index].elapsed < states[index].activationDelay { + // Settled cards are done for good: skipping them before the + // elapsed bump keeps their state bit-identical frame to frame. + 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..293e783 100644 --- a/ComputerSolitaire/Animation/WinCelebrationController.swift +++ b/ComputerSolitaire/Animation/WinCelebrationController.swift @@ -11,16 +11,68 @@ final class WinCelebrationController { case completed } - private(set) var cards: [WinCascadeCardState] = [] + /// Immutable snapshot of the cascade's cards taken at launch (faces and + /// sizes never change in flight). The overlay builds its Canvas symbols + /// from this, and replacing it is what tells SwiftUI a new cascade — or a + /// resynced settled pile — needs rendering; the live `cards` below stay + /// 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, advanced by `tick` once per display frame. + /// Outside observation on purpose: the cascade Canvas reads it from its + /// renderer closure (untracked), and the per-frame mutation must not + /// schedule a second SwiftUI update on top of the TimelineView's own. + @ObservationIgnored private(set) var cards: [WinCascadeCardState] = [] + @ObservationIgnored private var lastTickDate: Date? + @ObservationIgnored private var isCompletionScheduled = false + /// Bumped whenever the cascade's lifecycle restarts, so a completion + /// deferred from `tick` can never land on a different cascade than the + /// one that settled. + @ObservationIgnored private var cascadeGeneration = 0 var isAnimating: Bool { phase == .animating } + /// Advances the physics by the real time elapsed since the previous + /// frame. Called from the cascade Canvas renderer on each TimelineView + /// frame, so the simulation runs at the display's native cadence (120 Hz + /// on ProMotion) and a slow frame skips ahead instead of stretching the + /// animation into slow motion — `step` clamps the delta to keep the + /// physics stable across hitches. + 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.step( + states: &cards, + deltaTime: deltaTime, + boardBounds: boardBounds + ) + + if !cards.isEmpty, !isCompletionScheduled, cards.allSatisfy(\.isSettled) { + // The renderer is no place to publish observable state — defer + // the phase flip to the next main-actor turn. + 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 +93,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 +109,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 +120,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 +163,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..d12903c 100644 --- a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift @@ -138,31 +138,83 @@ struct UndoOverlayView: View { } } +/// The falling-cards celebration, rendered as a single Canvas: a TimelineView +/// supplies the display's frame clock, the renderer advances the physics by +/// the real elapsed time, and each card face resolves once as a Canvas symbol +/// that gets stamped with a per-frame transform. One view invalidation and +/// one draw pass per frame — never 52 live card views each dragging their own +/// shadow blur through the compositor. 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: + // The settled pile, static: redraws only when the controller + // publishes new launch states (relaunch into a won game). + cascadeCanvas(tickDate: nil) + } } .allowsHitTesting(false) .accessibilityHidden(true) } + + /// A `tickDate` advances the simulation before drawing; nil draws the + /// states as they stand. The canvas fills the overlay, so its size is the + /// live board viewport — 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: { + // Faces and sizes are fixed for the cascade's lifetime, so each + // card view resolves to a texture once and is stamped thereafter. + // No added drop shadow: the card artwork's own chrome shadow is + // all a resting card has, and the overlay copies must match it so + // launch doesn't pop an extra shadow on. The padding keeps that + // built-in shadow inside the rasterized bounds; being symmetric, + // it leaves the card centered so stamping stays position-exact. + 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) From 5f963c54af37a8faaace78ccf9025b5994ba7f5f Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 11 Aug 2026 22:09:09 -0700 Subject: [PATCH 2/3] tighten win cascade comments --- .../Animation/WinCascadeCoordinator.swift | 3 +- .../Animation/WinCelebrationController.swift | 31 +++++++------------ .../Views/Shared/BoardOverlayViews.swift | 27 ++++++---------- 3 files changed, 21 insertions(+), 40 deletions(-) diff --git a/ComputerSolitaire/Animation/WinCascadeCoordinator.swift b/ComputerSolitaire/Animation/WinCascadeCoordinator.swift index 93f9ff8..0c03f0c 100644 --- a/ComputerSolitaire/Animation/WinCascadeCoordinator.swift +++ b/ComputerSolitaire/Animation/WinCascadeCoordinator.swift @@ -80,8 +80,7 @@ enum WinCascadeCoordinator { let dt = CGFloat(max(1.0 / 120.0, min(1.0 / 30.0, deltaTime))) for index in states.indices { - // Settled cards are done for good: skipping them before the - // elapsed bump keeps their state bit-identical frame to frame. + // Skipped before the elapsed bump so settled state never changes. if states[index].isSettled { continue } diff --git a/ComputerSolitaire/Animation/WinCelebrationController.swift b/ComputerSolitaire/Animation/WinCelebrationController.swift index 293e783..1ed9d66 100644 --- a/ComputerSolitaire/Animation/WinCelebrationController.swift +++ b/ComputerSolitaire/Animation/WinCelebrationController.swift @@ -11,37 +11,29 @@ final class WinCelebrationController { case completed } - /// Immutable snapshot of the cascade's cards taken at launch (faces and - /// sizes never change in flight). The overlay builds its Canvas symbols - /// from this, and replacing it is what tells SwiftUI a new cascade — or a - /// resynced settled pile — needs rendering; the live `cards` below stay - /// invisible to observation. + /// 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 - /// Live simulation state, advanced by `tick` once per display frame. - /// Outside observation on purpose: the cascade Canvas reads it from its - /// renderer closure (untracked), and the per-frame mutation must not - /// schedule a second SwiftUI update on top of the TimelineView's own. + /// 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 - /// Bumped whenever the cascade's lifecycle restarts, so a completion - /// deferred from `tick` can never land on a different cascade than the - /// one that settled. + /// Keeps a deferred completion from landing on a later cascade. @ObservationIgnored private var cascadeGeneration = 0 var isAnimating: Bool { phase == .animating } - /// Advances the physics by the real time elapsed since the previous - /// frame. Called from the cascade Canvas renderer on each TimelineView - /// frame, so the simulation runs at the display's native cadence (120 Hz - /// on ProMotion) and a slow frame skips ahead instead of stretching the - /// animation into slow motion — `step` clamps the delta to keep the - /// physics stable across hitches. + /// 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 { @@ -60,8 +52,7 @@ final class WinCelebrationController { ) if !cards.isEmpty, !isCompletionScheduled, cards.allSatisfy(\.isSettled) { - // The renderer is no place to publish observable state — defer - // the phase flip to the next main-actor turn. + // Defer the observable phase flip out of the render pass. isCompletionScheduled = true let generation = cascadeGeneration Task { @MainActor [weak self] in diff --git a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift index d12903c..ac4f43b 100644 --- a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift @@ -138,12 +138,9 @@ struct UndoOverlayView: View { } } -/// The falling-cards celebration, rendered as a single Canvas: a TimelineView -/// supplies the display's frame clock, the renderer advances the physics by -/// the real elapsed time, and each card face resolves once as a Canvas symbol -/// that gets stamped with a per-frame transform. One view invalidation and -/// one draw pass per frame — never 52 live card views each dragging their own -/// shadow blur through the compositor. +/// 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 { let winCelebration: WinCelebrationController @@ -157,8 +154,6 @@ struct WinCascadeOverlayView: View { cascadeCanvas(tickDate: timeline.date) } case .completed: - // The settled pile, static: redraws only when the controller - // publishes new launch states (relaunch into a won game). cascadeCanvas(tickDate: nil) } } @@ -166,9 +161,9 @@ struct WinCascadeOverlayView: View { .accessibilityHidden(true) } - /// A `tickDate` advances the simulation before drawing; nil draws the - /// states as they stand. The canvas fills the overlay, so its size is the - /// live board viewport — the physics bounds track window resizes. + /// 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 { @@ -194,13 +189,9 @@ struct WinCascadeOverlayView: View { cardContext.draw(symbol, at: .zero) } } symbols: { - // Faces and sizes are fixed for the cascade's lifetime, so each - // card view resolves to a texture once and is stamped thereafter. - // No added drop shadow: the card artwork's own chrome shadow is - // all a resting card has, and the overlay copies must match it so - // launch doesn't pop an extra shadow on. The padding keeps that - // built-in shadow inside the rasterized bounds; being symmetric, - // it leaves the card centered so stamping stays position-exact. + // 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, From aef86c7712450addd67662e9a2c6596f76f7370b Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 11 Aug 2026 22:34:11 -0700 Subject: [PATCH 3/3] integrate cascade physics in fixed substeps to match wall time the tick previously clamped measured frame deltas to [1/120, 1/30]s, which discarded time beyond 33ms on slow frames and fabricated time on displays above 120hz. advance() now splits real frame time into <=1/60s substeps so simulated time matches wall time at any refresh rate, capped at 133ms per frame so a long stall jumps ahead instead of fast-forwarding. --- .../Animation/WinCascadeCoordinator.swift | 28 +++++++++++++++++-- .../Animation/WinCelebrationController.swift | 4 +-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/ComputerSolitaire/Animation/WinCascadeCoordinator.swift b/ComputerSolitaire/Animation/WinCascadeCoordinator.swift index 0c03f0c..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,15 +74,32 @@ 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 { // Skipped before the elapsed bump so settled state never changes. diff --git a/ComputerSolitaire/Animation/WinCelebrationController.swift b/ComputerSolitaire/Animation/WinCelebrationController.swift index 1ed9d66..12d1de4 100644 --- a/ComputerSolitaire/Animation/WinCelebrationController.swift +++ b/ComputerSolitaire/Animation/WinCelebrationController.swift @@ -45,9 +45,9 @@ final class WinCelebrationController { lastTickDate = date guard deltaTime > 0 else { return } - WinCascadeCoordinator.step( + WinCascadeCoordinator.advance( states: &cards, - deltaTime: deltaTime, + by: deltaTime, boardBounds: boardBounds )