From 075d4dc6c1eb334a9cc3086521392ff63a0afe42 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Wed, 15 Jul 2026 16:07:16 -0700 Subject: [PATCH 1/2] add animation speed setting --- .../Animation/MotionPolicy.swift | 72 +++++++++++++++ ComputerSolitaire/Views/Cards/CardView.swift | 15 +++- ComputerSolitaire/Views/MacSettingsView.swift | 2 +- ComputerSolitaire/Views/SettingsView.swift | 11 +++ .../Views/Shared/BoardOverlayViews.swift | 6 +- .../Views/Shared/ContentView.swift | 87 +++++++++++++------ 6 files changed, 162 insertions(+), 31 deletions(-) create mode 100644 ComputerSolitaire/Animation/MotionPolicy.swift diff --git a/ComputerSolitaire/Animation/MotionPolicy.swift b/ComputerSolitaire/Animation/MotionPolicy.swift new file mode 100644 index 0000000..614a93a --- /dev/null +++ b/ComputerSolitaire/Animation/MotionPolicy.swift @@ -0,0 +1,72 @@ +import SwiftUI + +/// The player's gameplay animation pace, from the Animation Speed setting. +/// `scale` multiplies every gameplay animation duration — moves, flights, +/// draws, deals, undo, flips — so the whole board keeps one rhythm. +struct AnimationSpeed: Identifiable, Equatable { + let id: String + let title: String + /// Multiplies gameplay animation durations; 0 means no motion at all. + let scale: Double + + static let normal = AnimationSpeed(id: "normal", title: "Normal", scale: 1) + static let fast = AnimationSpeed(id: "fast", title: "Fast", scale: 0.5) + static let instant = AnimationSpeed(id: "instant", title: "Instant", scale: 0) + + static let all: [AnimationSpeed] = [normal, fast, instant] + static let defaultValue: AnimationSpeed = .normal + + static func from(rawValue: String) -> AnimationSpeed { + all.first { $0.id == rawValue } ?? defaultValue + } +} + +/// One gate for every gameplay animation: the speed setting scales durations, +/// and the system Reduce Motion switch clamps to instant regardless of the +/// setting. Animation builders return `nil` when motion is off, which both +/// `withAnimation(_:)` and `.animation(_:value:)` treat as "apply without +/// animating". Completion scheduling must go through `duration(_:)` with the +/// same base value the builder received, so flights and their cleanup can +/// never drift apart. +/// +/// The win celebration is deliberately outside this policy's speed scaling — +/// it is a reward, not a wait — but Reduce Motion suppresses it too (see the +/// `isWin` observer in ContentView). +struct MotionPolicy: Equatable { + /// 1 normal, 0.5 fast, 0 instant or system Reduce Motion. + let scale: Double + + init(speed: AnimationSpeed, reduceMotion: Bool) { + scale = reduceMotion ? 0 : speed.scale + } + + var isInstant: Bool { scale == 0 } + + /// A flight or completion delay at the current pace. + func duration(_ base: Double) -> Double { + base * scale + } + + func spring(response: Double, dampingFraction: Double) -> Animation? { + isInstant ? nil : .spring(response: response * scale, dampingFraction: dampingFraction) + } + + func easeOut(_ baseDuration: Double) -> Animation? { + isInstant ? nil : .easeOut(duration: baseDuration * scale) + } + + func easeInOut(_ baseDuration: Double) -> Animation? { + isInstant ? nil : .easeInOut(duration: baseDuration * scale) + } +} + +private struct MotionPolicyKey: EnvironmentKey { + static let defaultValue = MotionPolicy(speed: .defaultValue, reduceMotion: false) +} + +extension EnvironmentValues { + var motionPolicy: MotionPolicy { + get { self[MotionPolicyKey.self] } + set { self[MotionPolicyKey.self] = newValue } + } +} diff --git a/ComputerSolitaire/Views/Cards/CardView.swift b/ComputerSolitaire/Views/Cards/CardView.swift index 4235de4..1e5c571 100644 --- a/ComputerSolitaire/Views/Cards/CardView.swift +++ b/ComputerSolitaire/Views/Cards/CardView.swift @@ -111,6 +111,7 @@ enum HintWiggleStyle { struct HintWiggleModifier: ViewModifier { let token: UUID? + @Environment(\.motionPolicy) private var motion @State private var wiggleAngle: Double = 0 @State private var wiggleTask: Task? @@ -137,6 +138,11 @@ struct HintWiggleModifier: ViewModifier { } private func startHintWiggle() { + // No wiggle without motion — the hint highlight alone marks the + // cards. Fast deliberately keeps the normal pace: the wiggle is an + // affordance that plays alongside the game, not a wait, and halving + // it just reads as twitchy. + guard !motion.isInstant else { return } wiggleTask?.cancel() wiggleTask = Task { @MainActor in for angle in HintWiggleStyle.angles { @@ -173,6 +179,9 @@ struct CardView: View { @State private var flipRotation: Double @State private var tiltAngle: Double = 0 @Environment(\.cardStyle) private var cardStyle + // Environment values self-invalidate as DynamicProperties, so neither + // needs a place in the Equatable check below. + @Environment(\.motionPolicy) private var motion init( card: Card, @@ -222,7 +231,7 @@ struct CardView: View { .hintWiggle(token: hintWiggleToken) .scaleEffect(isSelected ? 1.03 : 1) .onChange(of: card.isFaceUp) { _, newValue in - withAnimation(.easeInOut(duration: 0.32)) { + withAnimation(motion.easeInOut(0.32)) { flipRotation = newValue ? 0 : 180 } } @@ -231,7 +240,7 @@ struct CardView: View { // 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)) { + withAnimation(motion.easeOut(0.3)?.delay(motion.duration(flipDelay))) { flipRotation = 0 } } @@ -261,7 +270,7 @@ struct CardView: View { } private func animateTilt(to target: Double) { - withAnimation(.easeOut(duration: 0.2)) { + withAnimation(motion.easeOut(0.2)) { tiltAngle = target } } diff --git a/ComputerSolitaire/Views/MacSettingsView.swift b/ComputerSolitaire/Views/MacSettingsView.swift index 797ef88..cccf35f 100644 --- a/ComputerSolitaire/Views/MacSettingsView.swift +++ b/ComputerSolitaire/Views/MacSettingsView.swift @@ -10,7 +10,7 @@ import SwiftUI struct MacSettingsView: View { private enum PaneMetrics { static let width: CGFloat = 500 - static let generalHeight: CGFloat = 360 + static let generalHeight: CGFloat = 410 static let appearanceHeight: CGFloat = 560 static let rulesHeight: CGFloat = 500 static let aboutHeight: CGFloat = 400 diff --git a/ComputerSolitaire/Views/SettingsView.swift b/ComputerSolitaire/Views/SettingsView.swift index fd20e00..8e71f91 100644 --- a/ComputerSolitaire/Views/SettingsView.swift +++ b/ComputerSolitaire/Views/SettingsView.swift @@ -58,6 +58,7 @@ enum SettingsKey { static let showStockCount = "settings.showStockCount" static let cardStyle = "settings.cardStyle" static let cardBackColor = "settings.cardBackColor" + static let animationSpeed = "settings.animationSpeed" } // MARK: - Shared rows @@ -83,6 +84,8 @@ struct SoundSettingsRows: View { /// The in-play visibility toggles, shared by the iOS settings sheet and the /// macOS settings window. struct GameplaySettingsRows: View { + @AppStorage(SettingsKey.animationSpeed) + private var animationSpeedRawValue = AnimationSpeed.defaultValue.id @AppStorage(SettingsKey.showGameStats) private var isGameStatsVisible = true @AppStorage(SettingsKey.showStockCount) private var isStockCountVisible = true @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = true @@ -103,6 +106,14 @@ struct GameplaySettingsRows: View { Text("Turn off to avoid spoilers about hint availability.") } .toggleStyle(.switch) + Picker("Animation speed", selection: $animationSpeedRawValue) { + ForEach(AnimationSpeed.all) { speed in + Text(speed.title).tag(speed.id) + } + } +#if os(iOS) + .pickerStyle(.navigationLink) +#endif } } diff --git a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift index ffe3cbb..b05bb39 100644 --- a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift @@ -82,6 +82,7 @@ private struct DrawOverlayCardView: View { let delay: Double let isCardTiltEnabled: Bool @Binding var cardTilts: [UUID: Double] + @Environment(\.motionPolicy) private var motion @State private var progress: CGFloat = 0 var body: some View { @@ -102,7 +103,10 @@ private struct DrawOverlayCardView: View { ) .position(x: currentX, y: currentY) .onAppear { - withAnimation(.spring(response: 0.32, dampingFraction: 0.86).delay(delay)) { + // Travel pace matches the coordinator plans' travelDuration; the + // completion in ContentView scales through the same policy, so + // the overlay always comes down after the cards have settled. + withAnimation(motion.spring(response: 0.32, dampingFraction: 0.86)?.delay(motion.duration(delay))) { progress = 1 } } diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 0d6a142..b4698fe 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -76,6 +76,7 @@ extension View { struct ContentView: View { @Environment(\.modelContext) private var modelContext @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var isReduceMotionEnabled #if os(iOS) @Environment(\.horizontalSizeClass) private var horizontalSizeClass #endif @@ -152,9 +153,22 @@ struct ContentView: View { @AppStorage(SettingsKey.cardStyle) private var cardStyleRawValue = CardStyle.defaultValue.rawValue @AppStorage(SettingsKey.tableBackgroundColor) private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue + @AppStorage(SettingsKey.animationSpeed) + private var animationSpeedRawValue = AnimationSpeed.defaultValue.id + + /// Every gameplay animation and completion delay routes through this + /// policy so the speed setting and system Reduce Motion scale them as one. + private var motion: MotionPolicy { + MotionPolicy( + speed: .from(rawValue: animationSpeedRawValue), + reduceMotion: isReduceMotionEnabled + ) + } /// The one move spring, applied per board region in `boardRoot`. - private static let boardSpring = Animation.spring(response: 0.35, dampingFraction: 0.86) + private var boardSpring: Animation? { + motion.spring(response: 0.35, dampingFraction: 0.86) + } private var gameVariant: GameVariant { GameVariant(rawValue: gameVariantRawValue) ?? .klondike @@ -208,6 +222,7 @@ struct ContentView: View { boardRoot(for: geometry) } .environment(\.cardStyle, currentCardStyle) + .environment(\.motionPolicy, motion) ) .accessibilityHidden(isShowingGamePicker) .overlay { @@ -486,11 +501,11 @@ struct ContentView: View { } .onChange(of: viewModel.hasActiveHint) { _, hasActiveHint in if !hasActiveHint { - withAnimation(.easeOut(duration: 0.3)) { + withAnimation(motion.easeOut(0.3)) { hintHighlightOpacity = 0 } } else { - withAnimation(.easeOut(duration: 0.12)) { + withAnimation(motion.easeOut(0.12)) { hintHighlightOpacity = 1 } } @@ -595,7 +610,7 @@ struct ContentView: View { guard abs(newHeight - headerHeight) >= 0.5 else { return } headerHeight = newHeight } - .animation(Self.boardSpring, value: viewModel.state) + .animation(boardSpring, value: viewModel.state) } TopRowView( session: viewModel, @@ -624,7 +639,7 @@ struct ContentView: View { // change never opens an animation transaction over the // whole board. The top row (stock, waste, foundations, // free cells) is small enough to key on the whole state. - .animation(Self.boardSpring, value: viewModel.state) + .animation(boardSpring, value: viewModel.state) if viewModel.gameVariant == .pyramid { PyramidBoardView( session: viewModel, @@ -644,7 +659,7 @@ struct ContentView: View { dragGesture: dragGesture(for:) ) .frame(width: boardContentWidth, alignment: .leading) - .animation(Self.boardSpring, value: viewModel.state.pyramid) + .animation(boardSpring, value: viewModel.state.pyramid) } else if viewModel.gameVariant == .tripeaks { TriPeaksBoardView( session: viewModel, @@ -661,7 +676,7 @@ struct ContentView: View { dragGesture: dragGesture(for:) ) .frame(width: boardContentWidth, alignment: .leading) - .animation(Self.boardSpring, value: viewModel.state.triPeaks) + .animation(boardSpring, value: viewModel.state.triPeaks) } else if viewModel.gameVariant == .canfield { CanfieldBoardRowView( session: viewModel, @@ -686,7 +701,7 @@ struct ContentView: View { .frame(width: boardContentWidth, alignment: .leading) // Canfield's row renders the tableau and the reserve, // so it keys on the whole state like the top row. - .animation(Self.boardSpring, value: viewModel.state) + .animation(boardSpring, value: viewModel.state) } else { TableauRowView( session: viewModel, @@ -709,7 +724,7 @@ struct ContentView: View { dragGesture: dragGesture(for:) ) .frame(width: boardContentWidth, alignment: .leading) - .animation(Self.boardSpring, value: viewModel.state.tableau) + .animation(boardSpring, value: viewModel.state.tableau) } Spacer(minLength: 0) } @@ -770,16 +785,30 @@ struct ContentView: View { guard !isHydratingGame else { return } if isWin { HapticManager.shared.play(.gameWon) - winCelebration.beginIfNeededForWin( - launchPiles: winCascadeLaunchPiles, - launchTargets: winCascadeLaunchTargets, - dropFrames: dropFrames, - boardViewportSize: boardViewportSize - ) + if isReduceMotionEnabled { + // The cascade is full-screen motion — under Reduce Motion, + // present the settled end state the relaunch path uses. + // The Animation Speed setting deliberately does not touch + // the celebration: it is a reward, not a wait. + presentSettledCelebration() + } else { + winCelebration.beginIfNeededForWin( + launchPiles: winCascadeLaunchPiles, + launchTargets: winCascadeLaunchTargets, + dropFrames: dropFrames, + boardViewportSize: boardViewportSize + ) + } } else if winCelebration.phase != .idle { winCelebration.reset(to: .idle) } } + .onChange(of: isReduceMotionEnabled) { _, isEnabled in + // The win handler samples Reduce Motion once; a toggle while the + // cascade is mid-flight must land the cards, not finish the show. + guard isEnabled, winCelebration.isAnimating, viewModel.isWin else { return } + presentSettledCelebration() + } .onChange(of: viewModel.state.waste.count) { _, newValue in let stockCount = viewModel.state.stock.count if newValue == 0 { @@ -832,7 +861,7 @@ struct ContentView: View { guard !dealingCardIDs.isEmpty || !dealAnimationCards.isEmpty else { return } cancelDealAnimation() } - .animation(.easeInOut(duration: 0.12), value: drag.activeTarget) + .animation(motion.easeInOut(0.12), value: drag.activeTarget) .overlay { GeometryReader { _ in ZStack { @@ -1177,7 +1206,7 @@ struct ContentView: View { if let firstCard = request.selection.cards.first { overlayTilt = cardTilts[firstCard.id] ?? 0 let tiltSettleDuration = isAutoFinishing ? 0.1 : 0.15 - withAnimation(.easeOut(duration: tiltSettleDuration)) { + withAnimation(motion.easeOut(tiltSettleDuration)) { overlayTilt = 0 } } @@ -1237,7 +1266,7 @@ struct ContentView: View { HapticManager.shared.play(.cardPickUp) // Start with the card's current tilt, then animate to straight overlayTilt = cardTilts[firstCard.id] ?? 0 - withAnimation(.easeOut(duration: 0.15)) { + withAnimation(motion.easeOut(0.15)) { overlayTilt = 0 } } @@ -1295,11 +1324,11 @@ struct ContentView: View { ) let dropDuration = isAutoFinishing ? 0.18 : 0.25 - withAnimation(.spring(response: dropDuration, dampingFraction: 0.85)) { + withAnimation(motion.spring(response: dropDuration, dampingFraction: 0.85)) { dropAnimationOffset = offsetToTarget } - DispatchQueue.main.asyncAfter(deadline: .now() + dropDuration) { + DispatchQueue.main.asyncAfter(deadline: .now() + motion.duration(dropDuration)) { // Clear old tilts so cards get fresh tilts at new position if let cards = droppingSelection?.cards { for card in cards { @@ -1350,11 +1379,11 @@ struct ContentView: View { // Keep viewModel.isDragging true to hide original card during animation isReturningDrag = true dragReturnOffset = .zero - withAnimation(.spring(response: 0.32, dampingFraction: 0.9)) { + withAnimation(motion.spring(response: 0.32, dampingFraction: 0.9)) { dragReturnOffset = CGSize(width: -currentTranslation.width, height: -currentTranslation.height) overlayTilt = targetTilt } - let returnDuration = 0.32 + let returnDuration = motion.duration(0.32) DispatchQueue.main.asyncAfter(deadline: .now() + returnDuration) { viewModel.cancelDrag() wasteReturnAnchorCardID = nil @@ -1390,7 +1419,7 @@ struct ContentView: View { drawingCardIDs = plan.cardIDs drawAnimationToken = plan.token - DispatchQueue.main.asyncAfter(deadline: .now() + plan.travelDuration + plan.settleDuration) { + DispatchQueue.main.asyncAfter(deadline: .now() + motion.duration(plan.travelDuration + plan.settleDuration)) { guard drawAnimationToken == plan.token else { return } drawAnimationCards = [] drawingCardIDs = [] @@ -1458,7 +1487,7 @@ struct ContentView: View { // trimming the hidden set to the flying cards un-hides those. dealingCardIDs = plan.cardIDs - let total = plan.maxDelay + plan.travelDuration + plan.settleDuration + let total = motion.duration(plan.maxDelay + plan.travelDuration + plan.settleDuration) DispatchQueue.main.asyncAfter(deadline: .now() + total) { guard dealAnimationToken == token else { return } dealAnimationCards = [] @@ -1639,7 +1668,7 @@ struct ContentView: View { if !resolvedItems.isEmpty, hasMovement { undoAnimationItems = resolvedItems - withAnimation(.spring(response: 0.3, dampingFraction: 0.88)) { + withAnimation(motion.spring(response: 0.3, dampingFraction: 0.88)) { undoAnimationProgress = 1 } // One turn later — once the overlay views exist with their takeoff @@ -1662,7 +1691,7 @@ struct ContentView: View { } // Slightly past the flight spring AND the mid-air flip (which // starts a turn late and runs 0.32s) so neither gets clipped. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.38) { + DispatchQueue.main.asyncAfter(deadline: .now() + motion.duration(0.38)) { finishUndoAnimation() } return @@ -1943,6 +1972,12 @@ struct ContentView: View { guard hasLoadedGame, viewModel.isWin else { return } guard winCelebration.phase == .completed else { return } guard winCelebration.cards.isEmpty else { return } + presentSettledCelebration() + } + + /// The celebration's end state without the flight, as after a relaunch + /// into a won game. + private func presentSettledCelebration() { winCelebration.syncForLoadedGame( launchPiles: winCascadeLaunchPiles, launchTargets: winCascadeLaunchTargets, From 1fef04c5dac43e470a297ff5287dcf9889875894 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Wed, 15 Jul 2026 19:16:47 -0700 Subject: [PATCH 2/2] bump marketing version to 0.8.2 --- ComputerSolitaire.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ComputerSolitaire.xcodeproj/project.pbxproj b/ComputerSolitaire.xcodeproj/project.pbxproj index d3121d6..9cbbfce 100644 --- a/ComputerSolitaire.xcodeproj/project.pbxproj +++ b/ComputerSolitaire.xcodeproj/project.pbxproj @@ -324,7 +324,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 0.8.1; + MARKETING_VERSION = 0.8.2; PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.ComputerSolitaire; PRODUCT_NAME = "Computer Solitaire"; REGISTER_APP_GROUPS = YES; @@ -376,7 +376,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 0.8.1; + MARKETING_VERSION = 0.8.2; PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.ComputerSolitaire; PRODUCT_NAME = "Computer Solitaire"; REGISTER_APP_GROUPS = YES;