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
35 changes: 29 additions & 6 deletions ComputerSolitaire/Animation/WinCascadeCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]],
Expand Down Expand Up @@ -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
}

Expand Down
98 changes: 58 additions & 40 deletions ComputerSolitaire/Animation/WinCelebrationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID> = []
private(set) var phase: Phase = .idle

private var cascadeTask: Task<Void, Never>?
/// 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.
Expand All @@ -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
}

Expand All @@ -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,
Expand All @@ -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],
Expand Down Expand Up @@ -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<UUID> {
Expand Down
77 changes: 60 additions & 17 deletions ComputerSolitaire/Views/Shared/BoardOverlayViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 0 additions & 1 deletion ComputerSolitaire/Views/Shared/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,6 @@ struct ContentView: View {
initializeGameIfNeeded()
}
.onDisappear {
winCelebration.cancelTask()
persistGameNow()
}
#if os(macOS)
Expand Down