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
4 changes: 2 additions & 2 deletions ComputerSolitaire.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
72 changes: 72 additions & 0 deletions ComputerSolitaire/Animation/MotionPolicy.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
15 changes: 12 additions & 3 deletions ComputerSolitaire/Views/Cards/CardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?

Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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
}
}
Expand Down
2 changes: 1 addition & 1 deletion ComputerSolitaire/Views/MacSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions ComputerSolitaire/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
}
}

Expand Down
6 changes: 5 additions & 1 deletion ComputerSolitaire/Views/Shared/BoardOverlayViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}
Expand Down
Loading