diff --git a/ComputerSolitaire/Feedback/HapticManager.swift b/ComputerSolitaire/Feedback/HapticManager.swift index a0ef34e..97d4da5 100644 --- a/ComputerSolitaire/Feedback/HapticManager.swift +++ b/ComputerSolitaire/Feedback/HapticManager.swift @@ -1,32 +1,64 @@ import Observation import SwiftUI +#if os(iOS) +import CoreHaptics +#endif @MainActor @Observable final class HapticManager { static let shared = HapticManager() + /// False on iPads: they have no Taptic Engine. + static let deviceSupportsHaptics: Bool = { +#if os(iOS) + CHHapticEngine.capabilitiesForHardware().supportsHaptics +#else + false +#endif + }() + enum Event { case cardPickUp + case cardPlaced case stockDraw case wasteRecycle case cardFlipFaceUp + case runCompleted case invalidDrop case undoMove + case hintFound case settingsSelection + case gameSwitched + case dropTargetAcquired case autoFinishStart + case golfHoleDead + case destructiveActionConfirmed case gameWon } private(set) var trigger: UInt64 = 0 - private var lastEvent: Event? + private var pendingEvent: Event? + private var isCoalescingTick = false private init() {} + /// SwiftUI plays `sensoryFeedback` once per view update, so same-tick + /// calls collapse into one sensation; the highest-ranking event wins. func play(_ event: Event) { #if os(iOS) guard isHapticFeedbackEnabled else { return } - lastEvent = event + if isCoalescingTick { + if event.coalescingRank > (pendingEvent?.coalescingRank ?? Int.min) { + pendingEvent = event + } + } else { + pendingEvent = event + isCoalescingTick = true + Task { @MainActor in + self.isCoalescingTick = false + } + } trigger &+= 1 #endif } @@ -41,8 +73,7 @@ final class HapticManager { var feedbackForTrigger: SensoryFeedback? { #if os(iOS) - guard let lastEvent else { return nil } - return lastEvent.sensoryFeedback + return pendingEvent?.sensoryFeedback #else return nil #endif @@ -53,23 +84,52 @@ private extension HapticManager.Event { var sensoryFeedback: SensoryFeedback { switch self { case .cardPickUp: - return .impact + return .impact(flexibility: .soft, intensity: 0.6) + case .cardPlaced: + return .impact(flexibility: .rigid, intensity: 0.6) case .stockDraw: - return .impact + return .impact(weight: .light) case .wasteRecycle: - return .impact + return .impact(weight: .medium) case .cardFlipFaceUp: return .selection - case .undoMove: - return .impact + case .runCompleted: + return .success case .invalidDrop: return .error + case .undoMove: + return .selection + case .hintFound: + return .selection case .settingsSelection: - return .impact + return .selection + case .gameSwitched: + return .selection + case .dropTargetAcquired: + return .selection case .autoFinishStart: - return .impact + return .impact(weight: .medium) + case .golfHoleDead: + return .warning + case .destructiveActionConfirmed: + return .warning case .gameWon: return .success } } + + /// Outcomes beat impacts beat selection ticks. + var coalescingRank: Int { + switch self { + case .gameWon, .runCompleted: + return 3 + case .invalidDrop, .golfHoleDead, .destructiveActionConfirmed: + return 2 + case .cardPickUp, .cardPlaced, .stockDraw, .wasteRecycle, .autoFinishStart: + return 1 + case .cardFlipFaceUp, .undoMove, .hintFound, .settingsSelection, + .gameSwitched, .dropTargetAcquired: + return 0 + } + } } diff --git a/ComputerSolitaire/Game/Canfield/GameSessionCanfield.swift b/ComputerSolitaire/Game/Canfield/GameSessionCanfield.swift index 6afa4d8..dac0abd 100644 --- a/ComputerSolitaire/Game/Canfield/GameSessionCanfield.swift +++ b/ComputerSolitaire/Game/Canfield/GameSessionCanfield.swift @@ -108,5 +108,6 @@ extension SolitaireViewModel { CanfieldGameRules.refillEmptyPileFromReserve(on: &state, pileIndex: pileIndex) applyScore(.reserveToTableau) SoundManager.shared.play(.cardPlaced) + HapticManager.shared.play(.cardPlaced) } } diff --git a/ComputerSolitaire/Game/Golf/GameSessionGolf.swift b/ComputerSolitaire/Game/Golf/GameSessionGolf.swift index 8cc1513..d2f8971 100644 --- a/ComputerSolitaire/Game/Golf/GameSessionGolf.swift +++ b/ComputerSolitaire/Game/Golf/GameSessionGolf.swift @@ -63,6 +63,7 @@ extension SolitaireViewModel { applyTimeBonusIfWon() self.selection = nil SoundManager.shared.play(.cardPlaced) + HapticManager.shared.play(.cardPlaced) refreshAutoFinishAvailability() return true } diff --git a/ComputerSolitaire/Game/Pyramid/GameSessionPyramid.swift b/ComputerSolitaire/Game/Pyramid/GameSessionPyramid.swift index 717fd05..18b58ff 100644 --- a/ComputerSolitaire/Game/Pyramid/GameSessionPyramid.swift +++ b/ComputerSolitaire/Game/Pyramid/GameSessionPyramid.swift @@ -54,6 +54,7 @@ extension SolitaireViewModel { applyTimeBonusIfWon() self.selection = nil SoundManager.shared.play(.cardPlaced) + HapticManager.shared.play(.cardPlaced) refreshAutoFinishAvailability() return true } diff --git a/ComputerSolitaire/Game/Scorpion/GameSessionScorpion.swift b/ComputerSolitaire/Game/Scorpion/GameSessionScorpion.swift index 75afe5d..8e2ce0c 100644 --- a/ComputerSolitaire/Game/Scorpion/GameSessionScorpion.swift +++ b/ComputerSolitaire/Game/Scorpion/GameSessionScorpion.swift @@ -50,6 +50,6 @@ extension SolitaireViewModel { applyScore(.scorpionCompletedRun) } SoundManager.shared.play(.cardPlaced) - HapticManager.shared.play(.cardFlipFaceUp) + HapticManager.shared.play(.runCompleted) } } diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 4960ac1..524f8c4 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -199,7 +199,7 @@ final class SolitaireViewModel { hintWiggleToken = UUID() scheduleHintAutoClear(for: hint) hintRequestsInCurrentGame += 1 - HapticManager.shared.play(.settingsSelection) + HapticManager.shared.play(.hintFound) } func clearHint() { @@ -374,6 +374,9 @@ final class SolitaireViewModel { isDragging = false pendingAutoMove = nil SoundManager.shared.play(.undoMove) + // Here rather than the view's undo-animation path, so every undo + // entry point buzzes. + HapticManager.shared.play(.undoMove) refreshAutoFinishAvailability() } @@ -956,6 +959,7 @@ extension SolitaireViewModel { applyTimeBonusIfWon() self.selection = nil SoundManager.shared.play(.cardPlaced) + HapticManager.shared.play(.cardPlaced) refreshAutoFinishAvailability() return true @@ -980,6 +984,7 @@ extension SolitaireViewModel { applyTimeBonusIfWon() self.selection = nil SoundManager.shared.play(.cardPlaced) + HapticManager.shared.play(.cardPlaced) refreshAutoFinishAvailability() return true @@ -999,6 +1004,7 @@ extension SolitaireViewModel { applyTimeBonusIfWon() self.selection = nil SoundManager.shared.play(.cardPlaced) + HapticManager.shared.play(.cardPlaced) refreshAutoFinishAvailability() return true diff --git a/ComputerSolitaire/Game/Spider/GameSessionSpider.swift b/ComputerSolitaire/Game/Spider/GameSessionSpider.swift index 28c2c7b..0ea5c1f 100644 --- a/ComputerSolitaire/Game/Spider/GameSessionSpider.swift +++ b/ComputerSolitaire/Game/Spider/GameSessionSpider.swift @@ -65,6 +65,6 @@ extension SolitaireViewModel { applyScore(.spiderCompletedRun) } SoundManager.shared.play(.cardPlaced) - HapticManager.shared.play(.cardFlipFaceUp) + HapticManager.shared.play(.runCompleted) } } diff --git a/ComputerSolitaire/Game/TriPeaks/GameSessionTriPeaks.swift b/ComputerSolitaire/Game/TriPeaks/GameSessionTriPeaks.swift index 74f5b1d..edbe0d5 100644 --- a/ComputerSolitaire/Game/TriPeaks/GameSessionTriPeaks.swift +++ b/ComputerSolitaire/Game/TriPeaks/GameSessionTriPeaks.swift @@ -53,6 +53,7 @@ extension SolitaireViewModel { applyTimeBonusIfWon() self.selection = nil SoundManager.shared.play(.cardPlaced) + HapticManager.shared.play(.cardPlaced) refreshAutoFinishAvailability() return true } diff --git a/ComputerSolitaire/Views/SettingsView.swift b/ComputerSolitaire/Views/SettingsView.swift index 37898e8..a35b988 100644 --- a/ComputerSolitaire/Views/SettingsView.swift +++ b/ComputerSolitaire/Views/SettingsView.swift @@ -75,8 +75,10 @@ struct SoundSettingsRows: View { Toggle("Sound effects", isOn: $isSoundEffectsEnabled) .toggleStyle(.switch) #if os(iOS) - Toggle("Haptic feedback", isOn: $isHapticFeedbackEnabled) - .toggleStyle(.switch) + if HapticManager.deviceSupportsHaptics { + Toggle("Haptic feedback", isOn: $isHapticFeedbackEnabled) + .toggleStyle(.switch) + } #endif } } @@ -224,7 +226,7 @@ struct SettingsView: View { Section { SoundSettingsRows() } header: { - Text("Sound & Haptics") + Text(HapticManager.deviceSupportsHaptics ? "Sound & Haptics" : "Sound") } } diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 8587cd0..0aec298 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -141,6 +141,8 @@ struct ContentView: View { @State private var previousStockCount: Int = 0 @State private var hasLoadedGame = false @State private var isHydratingGame = false + /// Outlives `isHydratingGame` by one tick, covering `onChange` observers. + @State private var isSettlingHydratedGame = false // True while a screenshot board is on screen; suppresses autosave so the // staged game never overwrites the real one. Only set in DEBUG builds. @State private var isScreenshotSession = false @@ -467,7 +469,7 @@ struct ContentView: View { /// AppStorage selection in sync. private func requestGameSwitch(to mode: GameMode) { guard mode != viewModel.gameMode else { return } - hapticFeedback.play(.settingsSelection) + hapticFeedback.play(.gameSwitched) switchGame(to: mode) } @@ -822,7 +824,7 @@ struct ContentView: View { refreshLoadedWinPresentationIfNeeded() } .onChange(of: viewModel.isWin) { _, isWin in - guard !isHydratingGame else { return } + guard !isHydratingGame, !isSettlingHydratedGame else { return } if isWin { HapticManager.shared.play(.gameWon) if isReduceMotionEnabled { @@ -849,6 +851,15 @@ struct ContentView: View { guard isEnabled, winCelebration.isAnimating, viewModel.isWin else { return } presentSettledCelebration() } + .onChange(of: viewModel.isGolfHoleDead) { _, isDead in + guard !isHydratingGame, !isSettlingHydratedGame, isDead else { return } + HapticManager.shared.play(.golfHoleDead) + } + .onChange(of: viewModel.golfMatch.isComplete) { _, isComplete in + guard !isHydratingGame, !isSettlingHydratedGame, isComplete else { return } + // A finished match is Golf's win. + HapticManager.shared.play(.gameWon) + } .onChange(of: viewModel.state.waste.count) { _, newValue in let stockCount = viewModel.state.stock.count if newValue == 0 { @@ -1240,6 +1251,7 @@ struct ContentView: View { winCelebration.reset(to: .idle) resetTransientBoardState() isHydratingGame = true + isSettlingHydratedGame = true isScreenshotSession = false let payload = GamePersistence.load(mode: mode, from: modelContext) viewModel.activateGame(mode, restoringFrom: payload) @@ -1256,6 +1268,9 @@ struct ContentView: View { previousStockCount = viewModel.state.stock.count isHydratingGame = false persistGameNow() + Task { @MainActor in + isSettlingHydratedGame = false + } } /// Clears in-flight drag/drop/undo/draw animation state so stale animation @@ -1358,7 +1373,13 @@ struct ContentView: View { if !started { return } } drag.dragTranslation = value.translation - drag.setActiveTarget(dropTarget(at: value.location)) + let newTarget = dropTarget(at: value.location) + if newTarget != drag.activeTarget, + let newTarget, + viewModel.canDrop(to: destination(for: newTarget)) { + HapticManager.shared.play(.dropTargetAcquired) + } + drag.setActiveTarget(newTarget) } .onEnded { _ in finishDrag() @@ -1760,7 +1781,6 @@ struct ContentView: View { guard !viewModel.isDragging, !isDroppingCards, !isReturningDrag else { return } guard !viewModel.isWin else { return } guard let snapshot = viewModel.peekUndoSnapshot() else { return } - HapticManager.shared.play(.undoMove) // Undo mutates the position a live deal flight refers to; land the // flight before its reverse begins so the two never run concurrently. cancelDealAnimation() @@ -2078,10 +2098,14 @@ struct ContentView: View { guard !hasLoadedGame else { return } hasLoadedGame = true isHydratingGame = true + isSettlingHydratedGame = true defer { isHydratingGame = false previousWasteCount = viewModel.state.waste.count previousStockCount = viewModel.state.stock.count + Task { @MainActor in + isSettlingHydratedGame = false + } } let migratedCurrentMode = GamePersistence.migrateLegacyRecordsIfNeeded(in: modelContext) diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index a80ab05..8f02412 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -125,6 +125,7 @@ private struct StatisticsOverviewView: View { titleVisibility: .visible ) { Button("Reset All Statistics", role: .destructive) { + HapticManager.shared.play(.destructiveActionConfirmed) resetAllStatistics() } Button("Cancel", role: .cancel) {} @@ -333,6 +334,7 @@ private struct GameStatisticsDetailView: View { titleVisibility: .visible ) { Button("Reset \(effectiveScopeTitle) Statistics", role: .destructive) { + HapticManager.shared.play(.destructiveActionConfirmed) resetStatistics() } Button("Cancel", role: .cancel) {}