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
82 changes: 71 additions & 11 deletions ComputerSolitaire/Feedback/HapticManager.swift
Original file line number Diff line number Diff line change
@@ -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
}
Expand All @@ -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
Expand All @@ -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
}
}
}
1 change: 1 addition & 0 deletions ComputerSolitaire/Game/Canfield/GameSessionCanfield.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,5 +108,6 @@ extension SolitaireViewModel {
CanfieldGameRules.refillEmptyPileFromReserve(on: &state, pileIndex: pileIndex)
applyScore(.reserveToTableau)
SoundManager.shared.play(.cardPlaced)
HapticManager.shared.play(.cardPlaced)
}
}
1 change: 1 addition & 0 deletions ComputerSolitaire/Game/Golf/GameSessionGolf.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ extension SolitaireViewModel {
applyTimeBonusIfWon()
self.selection = nil
SoundManager.shared.play(.cardPlaced)
HapticManager.shared.play(.cardPlaced)
refreshAutoFinishAvailability()
return true
}
Expand Down
1 change: 1 addition & 0 deletions ComputerSolitaire/Game/Pyramid/GameSessionPyramid.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ extension SolitaireViewModel {
applyTimeBonusIfWon()
self.selection = nil
SoundManager.shared.play(.cardPlaced)
HapticManager.shared.play(.cardPlaced)
refreshAutoFinishAvailability()
return true
}
Expand Down
2 changes: 1 addition & 1 deletion ComputerSolitaire/Game/Scorpion/GameSessionScorpion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,6 @@ extension SolitaireViewModel {
applyScore(.scorpionCompletedRun)
}
SoundManager.shared.play(.cardPlaced)
HapticManager.shared.play(.cardFlipFaceUp)
HapticManager.shared.play(.runCompleted)
}
}
8 changes: 7 additions & 1 deletion ComputerSolitaire/Game/Shared/GameSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ final class SolitaireViewModel {
hintWiggleToken = UUID()
scheduleHintAutoClear(for: hint)
hintRequestsInCurrentGame += 1
HapticManager.shared.play(.settingsSelection)
HapticManager.shared.play(.hintFound)
}

func clearHint() {
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -956,6 +959,7 @@ extension SolitaireViewModel {
applyTimeBonusIfWon()
self.selection = nil
SoundManager.shared.play(.cardPlaced)
HapticManager.shared.play(.cardPlaced)
refreshAutoFinishAvailability()
return true

Expand All @@ -980,6 +984,7 @@ extension SolitaireViewModel {
applyTimeBonusIfWon()
self.selection = nil
SoundManager.shared.play(.cardPlaced)
HapticManager.shared.play(.cardPlaced)
refreshAutoFinishAvailability()
return true

Expand All @@ -999,6 +1004,7 @@ extension SolitaireViewModel {
applyTimeBonusIfWon()
self.selection = nil
SoundManager.shared.play(.cardPlaced)
HapticManager.shared.play(.cardPlaced)
refreshAutoFinishAvailability()
return true

Expand Down
2 changes: 1 addition & 1 deletion ComputerSolitaire/Game/Spider/GameSessionSpider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,6 @@ extension SolitaireViewModel {
applyScore(.spiderCompletedRun)
}
SoundManager.shared.play(.cardPlaced)
HapticManager.shared.play(.cardFlipFaceUp)
HapticManager.shared.play(.runCompleted)
}
}
1 change: 1 addition & 0 deletions ComputerSolitaire/Game/TriPeaks/GameSessionTriPeaks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ extension SolitaireViewModel {
applyTimeBonusIfWon()
self.selection = nil
SoundManager.shared.play(.cardPlaced)
HapticManager.shared.play(.cardPlaced)
refreshAutoFinishAvailability()
return true
}
Expand Down
8 changes: 5 additions & 3 deletions ComputerSolitaire/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -224,7 +226,7 @@ struct SettingsView: View {
Section {
SoundSettingsRows()
} header: {
Text("Sound & Haptics")
Text(HapticManager.deviceSupportsHaptics ? "Sound & Haptics" : "Sound")
}
}

Expand Down
32 changes: 28 additions & 4 deletions ComputerSolitaire/Views/Shared/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Comment thread
austin-smith marked this conversation as resolved.
}
.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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions ComputerSolitaire/Views/StatisticsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down Expand Up @@ -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) {}
Expand Down