// // Haptics.swift // Keging // // 진동 패턴 재생 (iOS) — 시스템 바이브(1회 ≈0.5초)를 완료 콜백으로 체이닝한다. // ⚠️ 재생 중에 같은 시스템 사운드를 다시 호출하면 무시되므로(실기기 실측 — 겹침·짧은 // 간격 오프셋이 전부 먹혀 패턴이 똑같아지던 버그) 반드시 완료 후 다음 진동을 쏜다. // AudioServices 바이브는 무음 스위치 무관, 오디오 세션이 살아 있으면 백그라운드에서도 울린다. // import AudioToolbox import Foundation @MainActor enum Haptics { /// 진행 중 체인 취소용 세대 토큰 — 새 패턴이 시작되면 이전 체인의 남은 진동을 버린다 /// (수축·이완이 짧을 때 이전 체인이 다음 국면의 첫 진동을 삼키는 것 방지) private static var generation = 0 static func play(_ pattern: HapticPattern) { // 각 진동 앞의 쉼(초) — 첫 진동은 즉시 let gaps: [TimeInterval] switch pattern { case .short: gaps = [0] // 톡: 1방 case .doubleTap: gaps = [0, 0.25] // 톡톡: 완료 후 0.25초 쉬고 1방 더 case .rattle: gaps = [0, 0.15, 0.15] // 드르륵: 짧은 쉼으로 3연타 case .long: gaps = [0, 0, 0] // 지잉: 완료 즉시 이어 붙여 긴 진동감 } generation += 1 playChain(gaps, generation: generation) } private static func playChain(_ gaps: [TimeInterval], generation: Int) { guard generation == Self.generation, let gap = gaps.first else { return } let rest = Array(gaps.dropFirst()) Task { @MainActor in if gap > 0 { try? await Task.sleep(for: .seconds(gap)) } guard generation == Self.generation else { return } AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate) { @MainActor @Sendable in playChain(rest, generation: generation) } } } }